From 8456b38deacf0982f0bd4427f2f4549cc7ba54f3 Mon Sep 17 00:00:00 2001 From: M Date: Sun, 8 Mar 2026 15:38:54 +1100 Subject: [PATCH 1/4] feat: import follow list with Namecoin resolution during signup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new signup step that lets users bootstrap their feed by importing another profile's follow list. Supports all identifier types: - npub1... (NIP-19 bech32) - 64-char hex pubkey - alice@example.com (NIP-05 HTTP) - alice@example.bit / d/name / id/name (Namecoin blockchain) Flow: Enter identifier → Resolve → Fetch kind 3 → Preview with select/deselect → Apply follows → Done. Users can search multiple profiles before continuing. Changes to existing files: - NamecoinNameResolver: add resolveDetailed() + NamecoinResolveOutcome - NamecoinNameService: add resolveDetailed(), resolvePubkey() - AccountSessionManager: add isNewAccount flag, finishNewAccountSetup() - AccountScreen: route new accounts to ImportFollowListSection New files: - FollowListImporter: identifier resolution + kind 3 fetch + parsing - ImportFollowListViewModel: state machine for the import flow - ImportFollowListScreen: UI with preview, select/deselect, search again - FollowListImporterTest: 14 unit tests --- .../followimport/FollowListImporter.kt | 257 +++++++++ .../service/namecoin/NamecoinNameService.kt | 12 + .../amethyst/ui/screen/AccountScreen.kt | 106 +++- .../ui/screen/AccountSessionManager.kt | 15 +- .../screen/signup/ImportFollowListScreen.kt | 545 ++++++++++++++++++ .../signup/ImportFollowListViewModel.kt | 174 ++++++ .../followimport/FollowListImporterTest.kt | 268 +++++++++ .../namecoin/NamecoinNameResolver.kt | 75 +++ 8 files changed, 1447 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListViewModel.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporterTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporter.kt new file mode 100644 index 0000000000..361cea2ae4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporter.kt @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.followimport + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +/** + * A single entry from a follow list. + */ +data class FollowEntry( + val pubkeyHex: String, + val relayHint: String? = null, + val petname: String? = null, +) + +/** + * Result of fetching a user's follow list. + */ +sealed class FollowListResult { + data class Success( + val sourcePubkeyHex: String, + val follows: List, + val createdAt: Long, + /** If the identifier was resolved via Namecoin, this holds the .bit name */ + val resolvedViaNamecoin: String? = null, + ) : FollowListResult() + + data object NoFollowList : FollowListResult() + + data class InvalidIdentifier( + val reason: String, + ) : FollowListResult() + + data class Error( + val message: String, + ) : FollowListResult() +} + +/** + * Minimal representation of a kind 3 event received from a relay callback. + * Avoids coupling to any specific Event class. + */ +data class Kind3EventData( + val pTags: List>, + val createdAt: Long, +) + +/** + * Fetches another user's follow list from Nostr relays. + * + * Resolution order for identifiers: + * 1. Namecoin (.bit / d/ / id/) → ElectrumX blockchain query + * 2. Hex pubkey (64 hex chars) + * 3. npub1... (NIP-19 bech32) + * 4. NIP-05 (user@domain) → HTTP /.well-known/nostr.json + * + * @param resolveNamecoin Optional Namecoin resolver. When provided, .bit/d//id/ identifiers + * will be resolved via ElectrumX. Pass `namecoinNameResolver::resolveDetailed`. + */ +class FollowListImporter( + private val resolveNamecoin: (suspend (String) -> NamecoinResolveOutcome)? = null, +) { + companion object { + const val KIND_CONTACT_LIST = 3 + const val DEFAULT_TIMEOUT_MS = 15_000L + private val HEX_PUBKEY_REGEX = Regex("^[0-9a-fA-F]{64}$") + private const val NPUB_PREFIX = "npub1" + } + + /** + * Resolve an identifier to a hex pubkey. + * + * Tries Namecoin first, then npub, hex, and finally NIP-05 (HTTP). + */ + suspend fun resolveIdentifier( + identifier: String, + resolveNip05: (suspend (String) -> String?)? = null, + ): ResolvedIdentifier? { + val trimmed = identifier.trim() + + // ── 1. Namecoin ──────────────────────────────────────────────── + if (resolveNamecoin != null && NamecoinNameResolver.isNamecoinIdentifier(trimmed)) { + val outcome = resolveNamecoin.invoke(trimmed) + return when (outcome) { + is NamecoinResolveOutcome.Success -> { + ResolvedIdentifier(outcome.result.pubkey, namecoinSource = trimmed) + } + + else -> { + null + } + } + } + + // ── 2. Direct hex pubkey ─────────────────────────────────────── + if (HEX_PUBKEY_REGEX.matches(trimmed)) { + return ResolvedIdentifier(trimmed.lowercase()) + } + + // ── 3. NIP-19 npub ──────────────────────────────────────────── + if (trimmed.startsWith(NPUB_PREFIX, ignoreCase = true)) { + return try { + val bytes = trimmed.bechToBytes() + if (bytes.size == 32) { + ResolvedIdentifier(bytes.toHexKey()) + } else { + null + } + } catch (_: Exception) { + null + } + } + + // ── 4. NIP-05 (HTTP) ────────────────────────────────────────── + if (trimmed.contains("@") && resolveNip05 != null) { + val pk = resolveNip05(trimmed) + if (pk != null) return ResolvedIdentifier(pk) + } + + // ── 5. Bare string — try as NIP-05 ──────────────────────────── + if (resolveNip05 != null && !trimmed.startsWith("nsec")) { + val pk = resolveNip05(trimmed) + if (pk != null) return ResolvedIdentifier(pk) + } + + return null + } + + /** + * Fetch the follow list for a given identifier. + */ + suspend fun fetchFollowList( + identifier: String, + relayUrls: List, + fetchEvent: suspend (kind: Int, author: String, limit: Int, onEvent: (Kind3EventData) -> Unit) -> AutoCloseable?, + resolveNip05: (suspend (String) -> String?)? = null, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + ): FollowListResult = + withContext(Dispatchers.IO) { + // 1. Resolve identifier + val resolved = resolveIdentifier(identifier, resolveNip05) + if (resolved == null) { + val msg = + if (resolveNamecoin != null && NamecoinNameResolver.isNamecoinIdentifier(identifier)) { + // Get detailed outcome for specific error message + val outcome = resolveNamecoin.invoke(identifier) + when (outcome) { + is NamecoinResolveOutcome.NameNotFound -> { + "Namecoin name \"$identifier\" does not exist on the blockchain. " + + "Check the spelling or register it with Electrum-NMC." + } + + is NamecoinResolveOutcome.NoNostrField -> { + "Namecoin name \"$identifier\" exists but has no \"nostr\" field. " + + "The owner needs to add a nostr pubkey to the name's value." + } + + is NamecoinResolveOutcome.ServersUnreachable -> { + "All Namecoin ElectrumX servers are unreachable. " + + "Check your internet connection and try again. (${outcome.message})" + } + + is NamecoinResolveOutcome.Timeout -> { + "Namecoin lookup for \"$identifier\" timed out. " + + "ElectrumX servers may be slow or unreachable — try again later." + } + + is NamecoinResolveOutcome.InvalidIdentifier -> { + "\"$identifier\" is not a valid Namecoin identifier. " + + "Use .bit domains, d/name, or id/name format." + } + + is NamecoinResolveOutcome.Success -> { + "Unexpected error resolving \"$identifier\"." + } + } + } else { + "Could not resolve \"$identifier\" to a public key. " + + "Enter an npub, hex pubkey, NIP-05, or Namecoin name (.bit / d/ / id/)." + } + return@withContext FollowListResult.InvalidIdentifier(msg) + } + + // 2. Fetch kind 3 + val deferred = CompletableDeferred() + val sub = + try { + fetchEvent(KIND_CONTACT_LIST, resolved.pubkeyHex, 1) { event -> + if (!deferred.isCompleted) deferred.complete(event) + } + } catch (e: Exception) { + return@withContext FollowListResult.Error("Failed to connect to relays: ${e.message}") + } + + val event = withTimeoutOrNull(timeoutMs) { deferred.await() } + try { + sub?.close() + } catch (_: Exception) { + } + + if (event == null) return@withContext FollowListResult.NoFollowList + + // 3. Parse p-tags + val follows = + event.pTags + .mapNotNull { tag -> + if (tag.isEmpty()) return@mapNotNull null + val pk = tag[0] + if (!HEX_PUBKEY_REGEX.matches(pk)) return@mapNotNull null + FollowEntry( + pubkeyHex = pk.lowercase(), + relayHint = tag.getOrNull(1)?.takeIf { it.isNotBlank() }, + petname = tag.getOrNull(2)?.takeIf { it.isNotBlank() }, + ) + }.distinctBy { it.pubkeyHex } + + FollowListResult.Success( + sourcePubkeyHex = resolved.pubkeyHex, + follows = follows, + createdAt = event.createdAt, + resolvedViaNamecoin = resolved.namecoinSource, + ) + } +} + +/** + * Internal: result of resolving an identifier, tracking whether Namecoin was used. + */ +data class ResolvedIdentifier( + val pubkeyHex: String, + val namecoinSource: String? = null, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt index d2240151bd..b37a4832cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -77,6 +78,17 @@ class NamecoinNameService( return result } + /** + * Resolve and return just the hex pubkey, or null. + * Convenience for follow-import integration. + */ + suspend fun resolvePubkey(identifier: String): String? = resolve(identifier)?.pubkey + + /** + * Resolve with detailed outcome for error reporting. + */ + suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome = resolver.resolveDetailed(identifier) + /** * Verify that a Namecoin name maps to the expected pubkey. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 4df5ab1346..4756e3fa9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -29,16 +29,27 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.followimport.Kind3EventData import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen +import com.vitorpamplona.amethyst.ui.screen.signup.ImportFollowListSection +import com.vitorpamplona.amethyst.ui.screen.signup.ImportFollowListViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable fun AccountScreen(accountSessionManager: AccountSessionManager) { @@ -55,9 +66,21 @@ fun AccountScreen(accountSessionManager: AccountSessionManager) { animationSpec = tween(durationMillis = 100), ) { state -> when (state) { - is AccountState.Loading -> LoadingSetup() - is AccountState.LoggedOff -> LoggedOffSetup(accountSessionManager) - is AccountState.LoggedIn -> LoggedInSetup(state, accountSessionManager) + is AccountState.Loading -> { + LoadingSetup() + } + + is AccountState.LoggedOff -> { + LoggedOffSetup(accountSessionManager) + } + + is AccountState.LoggedIn -> { + if (state.isNewAccount) { + NewAccountImportFollowsSetup(state.account, accountSessionManager) + } else { + LoggedInSetup(state, accountSessionManager) + } + } } } } @@ -116,3 +139,80 @@ fun LoggedInSetup( ) } } + +@Composable +fun NewAccountImportFollowsSetup( + account: Account, + accountSessionManager: AccountSessionManager, +) { + val importViewModel: ImportFollowListViewModel = viewModel() + + LaunchedEffect(account) { + importViewModel.configure( + fetchEvent = { kind, author, limit, onEvent -> + val filter = + Filter( + kinds = listOf(kind), + authors = listOf(author), + limit = limit, + ) + val relayUrls = + listOf( + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.nostr.band", + "wss://purplepag.es", + ) + val filterMap = + relayUrls.associate { url -> + RelayUrlNormalizer.normalize(url) to listOf(filter) + } + val listener = + object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is ContactListEvent) { + onEvent( + Kind3EventData( + pTags = + event.tags + .filter { it.size >= 2 && it[0] == "p" } + .map { it.drop(1) }, + createdAt = event.createdAt, + ), + ) + } + } + } + val subId = + com.vitorpamplona.quartz.nip01Core.relay.client.single + .newSubId() + Amethyst.instance.client.openReqSubscription(subId, filterMap, listener) + AutoCloseable { Amethyst.instance.client.close(subId) } + }, + ) + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + ImportFollowListSection( + onFollowsApplied = { entries -> + withContext(Dispatchers.IO) { + for (entry in entries) { + val user = account.cache.getOrCreateUser(entry.pubkeyHex) + account.follow(user) + } + } + }, + onSkip = { accountSessionManager.finishNewAccountSetup() }, + onDone = { accountSessionManager.finishNewAccountSetup() }, + viewModel = importViewModel, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 04ae6867e9..8a75475c6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -85,6 +85,7 @@ sealed class AccountState { class LoggedIn( val account: Account, var route: Route? = null, + val isNewAccount: Boolean = false, ) : AccountState() } @@ -181,10 +182,20 @@ class AccountSessionManager( fun startUI( accountSettings: AccountSettings, route: Route? = null, + isNewAccount: Boolean = false, ) { val account = accountsCache.loadAccount(accountSettings) _accountContent.update { - AccountState.LoggedIn(account, route) + AccountState.LoggedIn(account, route, isNewAccount = isNewAccount) + } + } + + fun finishNewAccountSetup() { + val current = _accountContent.value + if (current is AccountState.LoggedIn && current.isNewAccount) { + _accountContent.update { + AccountState.LoggedIn(current.account, current.route, isNewAccount = false) + } } } @@ -276,7 +287,7 @@ class AccountSessionManager( localPreferences.setDefaultAccount(accountSettings) - startUI(accountSettings) + startUI(accountSettings, isNewAccount = true) scope.launch(Dispatchers.IO) { delay(2000) // waits for the new user to connect to the new relays. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListScreen.kt new file mode 100644 index 0000000000..ebf81762ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListScreen.kt @@ -0,0 +1,545 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.signup + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.outlined.Circle +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.service.followimport.FollowEntry + +// ── Public entry points ──────────────────────────────────────────────── + +/** + * Embeddable section for the signup wizard. + */ +@Composable +fun ImportFollowListSection( + onFollowsApplied: suspend (List) -> Unit, + onSkip: () -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ImportFollowListViewModel = viewModel(), +) { + val state by viewModel.state.collectAsState() + + Column( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + ImportHeader() + Spacer(Modifier.height(20.dp)) + InputSection( + enabled = state is ImportFollowState.Idle || state is ImportFollowState.Error, + onLookup = { viewModel.startImport(it) }, + ) + Spacer(Modifier.height(16.dp)) + + Box(modifier = Modifier.weight(1f)) { + when (val s = state) { + is ImportFollowState.Idle -> { + IdleHint() + } + + is ImportFollowState.Resolving -> { + LoadingIndicator("Resolving ${s.identifier}…") + } + + is ImportFollowState.Fetching -> { + LoadingIndicator("Fetching follow list…") + } + + is ImportFollowState.Preview -> { + PreviewList( + state = s, + onToggle = { viewModel.toggleSelection(it) }, + onSelectAll = { viewModel.setSelectAll(it) }, + ) + } + + is ImportFollowState.Applying -> { + LoadingIndicator("Following ${s.count} accounts…") + } + + is ImportFollowState.Done -> { + DoneMessage(s.count, onDone) + } + + is ImportFollowState.Error -> { + ErrorMessage(s.message) { viewModel.reset() } + } + } + } + + Spacer(Modifier.height(16.dp)) + BottomActions(state, onSkip, { viewModel.applySelectedFollows(onFollowsApplied) }, onDone, { viewModel.reset() }) + } +} + +/** + * Standalone dialog for post-signup use (settings / profile screen). + */ +@Composable +fun ImportFollowListDialog( + onDismiss: () -> Unit, + onFollowsApplied: suspend (List) -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth().padding(8.dp), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + ImportFollowListSection( + onFollowsApplied = onFollowsApplied, + onSkip = onDismiss, + onDone = onDismiss, + modifier = Modifier.height(600.dp), + ) + } + } +} + +// ── Internal composables ─────────────────────────────────────────────── + +@Composable +private fun ImportHeader() { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.PersonAdd, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + "Import Follow List", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + "Start with a great feed by following the same people as someone you trust.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun InputSection( + enabled: Boolean, + onLookup: (String) -> Unit, +) { + var identifier by rememberSaveable { mutableStateOf("") } + val kb = LocalSoftwareKeyboardController.current + + Column { + OutlinedTextField( + value = identifier, + onValueChange = { identifier = it }, + label = { Text("Profile to import from") }, + placeholder = { Text("npub1…, alice@example.com, or example.bit") }, + singleLine = true, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + supportingText = { + Text( + "Supports npub, NIP-05, hex, and Namecoin (.bit / d/ / id/)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go), + keyboardActions = + KeyboardActions(onGo = { + kb?.hide() + onLookup(identifier) + }), + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { + kb?.hide() + onLookup(identifier) + }, + enabled = enabled && identifier.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { Text("Look Up Follow List") } + } +} + +@Composable +private fun IdleHint() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(24.dp)) { + Text( + "Tip", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Enter the profile of a friend or community leader. " + + "You can use their npub, NIP-05 address, or a Namecoin name " + + "like alice@example.bit or id/alice for blockchain-verified identities.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun LoadingIndicator(message: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(Modifier.size(40.dp), strokeWidth = 3.dp) + Spacer(Modifier.height(12.dp)) + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun PreviewList( + state: ImportFollowState.Preview, + onToggle: (String) -> Unit, + onSelectAll: (Boolean) -> Unit, +) { + Column(Modifier.fillMaxSize()) { + // Namecoin badge if resolved via blockchain + AnimatedVisibility( + visible = state.namecoinSource != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + NamecoinResolvedBadge(state.namecoinSource ?: "") + } + + // Summary + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${state.totalCount} accounts found", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + "${state.selectedCount} selected", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + // Select all + Row( + Modifier.fillMaxWidth().clickable { onSelectAll(state.selectedCount < state.totalCount) }.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = state.selectedCount == state.totalCount, + onCheckedChange = { onSelectAll(it) }, + ) + Spacer(Modifier.width(8.dp)) + Text("Select All", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + + HorizontalDivider() + + LazyColumn(contentPadding = PaddingValues(vertical = 4.dp), modifier = Modifier.fillMaxSize()) { + items(items = state.follows, key = { it.pubkeyHex }) { entry -> + FollowEntryRow(entry, entry.pubkeyHex in state.selected) { onToggle(entry.pubkeyHex) } + } + } + } +} + +/** + * Badge shown when the source profile was resolved via Namecoin blockchain. + */ +@Composable +private fun NamecoinResolvedBadge(namecoinSource: String) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .background( + color = Color(0xFF4A90D9).copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp), + ).padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("\u26D3", fontSize = 16.sp) // ⛓ chain link + Spacer(Modifier.width(8.dp)) + Column { + Text( + "Resolved via Namecoin", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF4A90D9), + ) + Text( + formatNamecoinDisplay(namecoinSource), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun FollowEntryRow( + entry: FollowEntry, + isSelected: Boolean, + onToggle: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (isSelected) Icons.Filled.CheckCircle else Icons.Outlined.Circle, + contentDescription = null, + tint = + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(10.dp)) + Box( + Modifier.size(32.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + entry.pubkeyHex.take(2).uppercase(), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + entry.petname ?: shortPubkey(entry.pubkeyHex), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (entry.petname != null) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (entry.petname != null) { + Text( + shortPubkey(entry.pubkeyHex), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (entry.relayHint != null) { + Text( + entry.relayHint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun DoneMessage( + count: Int, + onContinue: () -> Unit, +) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(12.dp)) + Text( + "Now following $count accounts", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(6.dp)) + Text( + "Your feed is ready.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ErrorMessage( + message: String, + onRetry: () -> Unit, +) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(12.dp)) + OutlinedButton(onClick = onRetry) { Text("Try Again") } + } + } +} + +@Composable +private fun BottomActions( + state: ImportFollowState, + onSkip: () -> Unit, + onApply: () -> Unit, + onDone: () -> Unit, + onSearchAnother: () -> Unit, +) { + when (state) { + is ImportFollowState.Preview -> { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + TextButton(onClick = onSkip) { Text("Skip") } + Row { + OutlinedButton(onClick = onSearchAnother, shape = RoundedCornerShape(12.dp)) { + Text("Search Another") + } + Spacer(Modifier.width(8.dp)) + Button(onClick = onApply, enabled = state.selectedCount > 0, shape = RoundedCornerShape(12.dp)) { + Text("Follow ${state.selectedCount} accounts") + } + } + } + } + + is ImportFollowState.Done -> { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OutlinedButton(onClick = onSearchAnother, shape = RoundedCornerShape(12.dp)) { + Text("Import More") + } + Button(onClick = onDone, shape = RoundedCornerShape(12.dp)) { Text("Continue") } + } + } + + is ImportFollowState.Idle, is ImportFollowState.Error -> { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onSkip) { Text("Skip for now") } + } + } + + else -> {} + } +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +private fun shortPubkey(hex: String): String = if (hex.length < 12) hex else "npub:${hex.take(8)}…${hex.takeLast(4)}" + +private fun formatNamecoinDisplay(source: String): String { + val s = source.trim() + return when { + s.startsWith("d/", ignoreCase = true) -> "${s.removePrefix("d/")}.bit" + s.startsWith("_@") -> s.removePrefix("_@") + else -> s + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListViewModel.kt new file mode 100644 index 0000000000..6d3688b394 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/signup/ImportFollowListViewModel.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.signup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.service.followimport.FollowEntry +import com.vitorpamplona.amethyst.service.followimport.FollowListImporter +import com.vitorpamplona.amethyst.service.followimport.FollowListResult +import com.vitorpamplona.amethyst.service.followimport.Kind3EventData +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +sealed class ImportFollowState { + data object Idle : ImportFollowState() + + data class Resolving( + val identifier: String, + ) : ImportFollowState() + + data class Fetching( + val pubkeyHex: String, + ) : ImportFollowState() + + data class Preview( + val sourcePubkeyHex: String, + val follows: List, + val selected: Set, + /** Non-null if the source was resolved via Namecoin blockchain */ + val namecoinSource: String? = null, + ) : ImportFollowState() { + val selectedCount get() = selected.size + val totalCount get() = follows.size + } + + data class Applying( + val count: Int, + ) : ImportFollowState() + + data class Done( + val count: Int, + ) : ImportFollowState() + + data class Error( + val message: String, + ) : ImportFollowState() +} + +class ImportFollowListViewModel : ViewModel() { + private val namecoinResolver = NamecoinNameResolver(electrumxClient = ElectrumXClient()) + private val importer = FollowListImporter(resolveNamecoin = namecoinResolver::resolveDetailed) + private val _state = MutableStateFlow(ImportFollowState.Idle) + val state: StateFlow = _state.asStateFlow() + + private var fetchEventFn: (suspend (Int, String, Int, (Kind3EventData) -> Unit) -> AutoCloseable?)? = null + private var resolveNip05Fn: (suspend (String) -> String?)? = null + private var relayUrls: List = + listOf( + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.nostr.band", + "wss://purplepag.es", + ) + + fun configure( + fetchEvent: suspend (Int, String, Int, (Kind3EventData) -> Unit) -> AutoCloseable?, + resolveNip05: (suspend (String) -> String?)? = null, + relayUrls: List? = null, + ) { + this.fetchEventFn = fetchEvent + this.resolveNip05Fn = resolveNip05 + if (relayUrls != null) this.relayUrls = relayUrls + } + + fun startImport(identifier: String) { + if (identifier.isBlank()) { + _state.value = ImportFollowState.Error("Please enter an identifier.") + return + } + val fetch = + fetchEventFn ?: run { + _state.value = ImportFollowState.Error("Not connected to relays.") + return + } + + viewModelScope.launch { + _state.value = ImportFollowState.Resolving(identifier) + val result = + importer.fetchFollowList( + identifier = identifier, + relayUrls = relayUrls, + fetchEvent = fetch, + resolveNip05 = resolveNip05Fn, + ) + _state.value = + when (result) { + is FollowListResult.Success -> { + if (result.follows.isEmpty()) { + ImportFollowState.Error("This user's follow list is empty.") + } else { + ImportFollowState.Preview( + sourcePubkeyHex = result.sourcePubkeyHex, + follows = result.follows, + selected = result.follows.map { it.pubkeyHex }.toSet(), + namecoinSource = result.resolvedViaNamecoin, + ) + } + } + + is FollowListResult.NoFollowList -> { + ImportFollowState.Error("No follow list found on relays for this user.") + } + + is FollowListResult.InvalidIdentifier -> { + ImportFollowState.Error(result.reason) + } + + is FollowListResult.Error -> { + ImportFollowState.Error(result.message) + } + } + } + } + + fun toggleSelection(pubkeyHex: String) { + val c = _state.value as? ImportFollowState.Preview ?: return + _state.value = c.copy(selected = if (pubkeyHex in c.selected) c.selected - pubkeyHex else c.selected + pubkeyHex) + } + + fun setSelectAll(all: Boolean) { + val c = _state.value as? ImportFollowState.Preview ?: return + _state.value = c.copy(selected = if (all) c.follows.map { it.pubkeyHex }.toSet() else emptySet()) + } + + fun applySelectedFollows(applyFollows: suspend (List) -> Unit) { + val c = _state.value as? ImportFollowState.Preview ?: return + val sel = c.follows.filter { it.pubkeyHex in c.selected } + _state.value = ImportFollowState.Applying(sel.size) + viewModelScope.launch { + try { + applyFollows(sel) + _state.value = ImportFollowState.Done(sel.size) + } catch (e: Exception) { + _state.value = ImportFollowState.Error("Failed: ${e.message}") + } + } + } + + fun reset() { + _state.value = ImportFollowState.Idle + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporterTest.kt new file mode 100644 index 0000000000..2fb8daa689 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/followimport/FollowListImporterTest.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.followimport + +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FollowListImporterTest { + private val importer = FollowListImporter() + + // Importer with a Namecoin resolver that always returns ServersUnreachable + // (simulates no real ElectrumX servers in test env) + private val importerWithNamecoin = + FollowListImporter( + resolveNamecoin = { identifier -> + NamecoinResolveOutcome.ServersUnreachable("Test: no servers available") + }, + ) + + // ── Hex pubkey resolution ────────────────────────────────────────── + + @Test + fun `resolves 64-char hex pubkey`() = + runBlocking { + val hex = "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" + val r = importer.resolveIdentifier(hex) + assertNotNull(r) + assertEquals(hex, r!!.pubkeyHex) + assertNull(r.namecoinSource) + } + + @Test + fun `lowercases hex pubkey`() = + runBlocking { + val hex = "B0635D6A9851D3AED0CD6C495B282167ACF761729078D975FC341B22650B07B9" + assertEquals(hex.lowercase(), importer.resolveIdentifier(hex)!!.pubkeyHex) + } + + @Test + fun `rejects short hex`() = + runBlocking { + assertNull(importer.resolveIdentifier("abcdef")) + } + + // ── npub resolution ──────────────────────────────────────────────── + + @Test + fun `resolves valid npub`() = + runBlocking { + val npub = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6" + val r = importer.resolveIdentifier(npub) + assertNotNull(r) + assertEquals(64, r!!.pubkeyHex.length) + assertTrue(r.pubkeyHex.matches(Regex("^[0-9a-f]{64}$"))) + assertNull(r.namecoinSource) + } + + @Test + fun `rejects invalid npub`() = + runBlocking { + assertNull(importer.resolveIdentifier("npub1invalid")) + } + + // ── NIP-05 resolution ────────────────────────────────────────────── + + @Test + fun `delegates NIP-05 to callback`() = + runBlocking { + val expected = "aaaa000000000000000000000000000000000000000000000000000000000001" + val r = importer.resolveIdentifier("[email protected]", resolveNip05 = { expected }) + assertNotNull(r) + assertEquals(expected, r!!.pubkeyHex) + assertNull(r.namecoinSource) + } + + @Test + fun `returns null for NIP-05 without resolver`() = + runBlocking { + assertNull(importer.resolveIdentifier("[email protected]")) + } + + // ── nsec rejection ───────────────────────────────────────────────── + + @Test + fun `rejects nsec private keys`() = + runBlocking { + assertNull( + importer.resolveIdentifier( + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + resolveNip05 = { "should-not-be-called" }, + ), + ) + } + + // ── Namecoin identifier detection ────────────────────────────────── + + @Test + fun `identifies dot-bit as Namecoin`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("alice@example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("_@example.bit")) + } + + @Test + fun `identifies d-slash as Namecoin`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("d/example")) + } + + @Test + fun `identifies id-slash as Namecoin`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("id/alice")) + } + + @Test + fun `rejects non-Namecoin identifiers`() { + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("[email protected]")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("npub1abc")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("")) + } + + // ── Kind 3 parsing ───────────────────────────────────────────────── + + @Test + fun `parses kind 3 p-tags with relay hints and petnames`() = + runBlocking { + val target = "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" + val followA = "aaaa000000000000000000000000000000000000000000000000000000000001" + val followB = "bbbb000000000000000000000000000000000000000000000000000000000002" + + val result = + importer.fetchFollowList( + identifier = target, + relayUrls = listOf("wss://test"), + fetchEvent = { kind, author, _, onEvent -> + assertEquals(3, kind) + assertEquals(target, author) + onEvent( + Kind3EventData( + pTags = + listOf( + listOf(followA, "wss://relay.example.com", "alice"), + listOf(followB, "", "bob"), + ), + createdAt = 1700000000L, + ), + ) + AutoCloseable {} + }, + ) + + assertTrue(result is FollowListResult.Success) + val s = result as FollowListResult.Success + assertEquals(2, s.follows.size) + assertEquals(followA, s.follows[0].pubkeyHex) + assertEquals("wss://relay.example.com", s.follows[0].relayHint) + assertEquals("alice", s.follows[0].petname) + assertEquals(followB, s.follows[1].pubkeyHex) + assertNull(s.follows[1].relayHint) + assertEquals("bob", s.follows[1].petname) + assertNull(s.resolvedViaNamecoin) + } + + @Test + fun `deduplicates follows`() = + runBlocking { + val pk = "aaaa000000000000000000000000000000000000000000000000000000000001" + val result = + importer.fetchFollowList( + identifier = pk, + relayUrls = listOf("wss://t"), + fetchEvent = { _, _, _, onEvent -> + onEvent(Kind3EventData(pTags = listOf(listOf(pk), listOf(pk)), createdAt = 1L)) + AutoCloseable {} + }, + ) + assertEquals(1, (result as FollowListResult.Success).follows.size) + } + + @Test + fun `skips invalid pubkeys in p-tags`() = + runBlocking { + val result = + importer.fetchFollowList( + identifier = "aaaa000000000000000000000000000000000000000000000000000000000001", + relayUrls = listOf("wss://t"), + fetchEvent = { _, _, _, onEvent -> + onEvent( + Kind3EventData( + pTags = listOf(listOf("tooshort"), listOf(""), listOf("zzzz" + "0".repeat(60))), + createdAt = 1L, + ), + ) + AutoCloseable {} + }, + ) + assertEquals(0, (result as FollowListResult.Success).follows.size) + } + + @Test + fun `returns NoFollowList on timeout`() = + runBlocking { + val result = + importer.fetchFollowList( + identifier = "aaaa000000000000000000000000000000000000000000000000000000000001", + relayUrls = listOf("wss://t"), + fetchEvent = { _, _, _, _ -> AutoCloseable {} }, + timeoutMs = 200, + ) + assertTrue(result is FollowListResult.NoFollowList) + } + + @Test + fun `returns Error on fetch exception`() = + runBlocking { + val result = + importer.fetchFollowList( + identifier = "aaaa000000000000000000000000000000000000000000000000000000000001", + relayUrls = listOf("wss://t"), + fetchEvent = { _, _, _, _ -> throw RuntimeException("Connection refused") }, + ) + assertTrue(result is FollowListResult.Error) + assertTrue((result as FollowListResult.Error).message.contains("Connection refused")) + } + + // ── Namecoin-specific error messages ─────────────────────────────── + + @Test + fun `gives Namecoin-specific error for dot-bit failure`() = + runBlocking { + // Namecoin resolution will fail because NamecoinNameService is not + // configured with real servers in a test environment. The importer + // should give a Namecoin-specific error message. + val result = + importerWithNamecoin.fetchFollowList( + identifier = "nonexistent.bit", + relayUrls = listOf("wss://test"), + fetchEvent = { _, _, _, _ -> AutoCloseable {} }, + timeoutMs = 500, + ) + assertTrue(result is FollowListResult.InvalidIdentifier) + assertTrue((result as FollowListResult.InvalidIdentifier).reason.contains("Namecoin")) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt index e9f4e07d0c..72abf7546a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt @@ -27,6 +27,36 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject +/** Detailed outcome of a Namecoin resolution attempt. */ +sealed class NamecoinResolveOutcome { + data class Success( + val result: NamecoinNostrResult, + ) : NamecoinResolveOutcome() + + /** The name does not exist on the Namecoin blockchain. */ + data class NameNotFound( + val name: String, + ) : NamecoinResolveOutcome() + + /** The name exists but has no valid "nostr" field in its value. */ + data class NoNostrField( + val name: String, + ) : NamecoinResolveOutcome() + + /** All ElectrumX servers were unreachable. */ + data class ServersUnreachable( + val message: String, + ) : NamecoinResolveOutcome() + + /** The identifier could not be parsed as a Namecoin name. */ + data class InvalidIdentifier( + val identifier: String, + ) : NamecoinResolveOutcome() + + /** Timed out waiting for a response. */ + data object Timeout : NamecoinResolveOutcome() +} + /** * Result of resolving a Namecoin name to Nostr identity data. */ @@ -88,6 +118,18 @@ class NamecoinNameResolver( } } + /** + * Resolve with detailed outcome for error reporting in UI flows. + */ + suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome { + val parsed = + parseIdentifier(identifier) + ?: return NamecoinResolveOutcome.InvalidIdentifier(identifier) + val result = + withTimeoutOrNull(lookupTimeoutMs) { performLookupDetailed(parsed) } + return result ?: NamecoinResolveOutcome.Timeout + } + // ── Identifier Parsing ───────────────────────────────────────────── /** @@ -175,6 +217,39 @@ class NamecoinNameResolver( } } + private suspend fun performLookupDetailed(parsed: ParsedIdentifier): NamecoinResolveOutcome { + val nameResult: NameShowResult + try { + nameResult = + electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) + ?: return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName) + } catch (e: NamecoinLookupException.NameNotFound) { + return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName) + } catch (e: NamecoinLookupException.NameExpired) { + return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName) + } catch (e: NamecoinLookupException.ServersUnreachable) { + return NamecoinResolveOutcome.ServersUnreachable( + e.message ?: "All ElectrumX servers unreachable", + ) + } + + val valueJson = + tryParseJson(nameResult.value) + ?: return NamecoinResolveOutcome.NoNostrField(parsed.namecoinName) + + val nostrResult = + when (parsed.namespace) { + Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed) + Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed) + } + + return if (nostrResult != null) { + NamecoinResolveOutcome.Success(nostrResult) + } else { + NamecoinResolveOutcome.NoNostrField(parsed.namecoinName) + } + } + /** * Extract Nostr data from a `d/` domain value. * From 55c45480b431c9277874901d50746f7b8e38a769 Mon Sep 17 00:00:00 2001 From: M Date: Sun, 8 Mar 2026 16:01:25 +1100 Subject: [PATCH 2/4] feat: add custom ElectrumX server settings for Namecoin resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'Namecoin Resolution' section that lets users configure custom ElectrumX servers for .bit / d/ / id/ name resolution. When custom servers are configured, they are used EXCLUSIVELY — the hardcoded public defaults are completely ignored. This gives privacy-conscious users full control over which servers observe their name lookups. Server format: host:port (TLS default) or host:port:tcp (plaintext). Supports .onion addresses for Tor-routed lookups. New files: NamecoinSettings.kt Immutable settings data class with server string parsing/formatting. NamecoinSharedPreferences.kt DataStore-backed persistence following the TorSharedPreferences pattern. Provides a synchronous snapshot for the serverListProvider lambda and observable StateFlow for the UI. NamecoinSettingsSection.kt Compose UI: enable/disable toggle, active server display with DEFAULT/CUSTOM badge, add/remove servers with validation, reset. NamecoinSettingsTest.kt Unit tests for parsing, formatting, round-trips, and edge cases. Integration: AppModules.kt — added namecoinPrefs (NamecoinSharedPreferences) and wired customServersOrNull into the existing serverListProvider so user-configured servers take priority over the Tor/default logic. Refactored from an earlier patch to avoid duplicating code already in main. The quartz-layer ElectrumXClient, NamecoinNameResolver, and supporting types are reused unchanged. --- .../com/vitorpamplona/amethyst/AppModules.kt | 18 +- .../preferences/NamecoinSharedPreferences.kt | 143 ++++++ .../service/namecoin/NamecoinSettings.kt | 94 ++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 +- .../loggedIn/privacy/PrivacyOptionsScreen.kt | 35 +- .../settings/NamecoinSettingsSection.kt | 416 ++++++++++++++++++ .../service/namecoin/NamecoinSettingsTest.kt | 181 ++++++++ 7 files changed, 882 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 7856ab8b2a..f9fe491d92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever +import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder @@ -105,6 +106,11 @@ class AppModules( TorSharedPreferences(appContext, applicationIOScope) } + // Namecoin ElectrumX server preferences (global, like Tor settings) + val namecoinPrefs by lazy { + NamecoinSharedPreferences(appContext, applicationIOScope) + } + // App services that should be run as soon as there are subscribers to their flows val locationManager = LocationState(appContext, applicationIOScope) val connManager = ConnectivityManager(appContext, applicationIOScope) @@ -156,11 +162,13 @@ class AppModules( NamecoinNameResolver( electrumxClient = namecoinElectrumxClient, serverListProvider = { - if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { - TOR_ELECTRUMX_SERVERS - } else { - DEFAULT_ELECTRUMX_SERVERS - } + // User-configured custom servers take priority + namecoinPrefs.customServersOrNull + ?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { + TOR_ELECTRUMX_SERVERS + } else { + DEFAULT_ELECTRUMX_SERVERS + } }, ) val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt new file mode 100644 index 0000000000..600cecaa98 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.coroutines.cancellation.CancellationException + +/** + * Persistent storage for [NamecoinSettings], following the same pattern as + * [TorSharedPreferences]. + * + * Uses the app-wide [sharedPreferencesDataStore] so Namecoin resolution + * settings (like Tor settings) are global — not per-account. + * + * The current settings are available synchronously via [settings] (a + * [StateFlow]) and can be read in non-suspend contexts (e.g. in a + * `serverListProvider` lambda). + */ +@Stable +class NamecoinSharedPreferences( + private val context: Context, + private val scope: CoroutineScope, +) { + private val json = Json { ignoreUnknownKeys = true } + + companion object { + val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled") + val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers") + } + + /** + * Current settings, loaded synchronously at init to avoid races. + */ + private val _settings = + MutableStateFlow( + runBlocking { loadFromDisk() ?: NamecoinSettings.DEFAULT }, + ) + val settings: StateFlow = _settings + + /** Synchronous snapshot — safe to call from `serverListProvider` lambdas. */ + val current: NamecoinSettings get() = _settings.value + + /** + * Parsed [ElectrumxServer] list from current custom settings, or `null` + * if the user hasn't configured any (meaning "use defaults"). + */ + val customServersOrNull: List? + get() = current.toElectrumxServers() + + // ── Mutators ─────────────────────────────────────────────────────── + + suspend fun setEnabled(enabled: Boolean) { + val updated = current.copy(enabled = enabled) + persist(updated) + } + + suspend fun addServer(server: String) { + if (server.isBlank() || server in current.customServers) return + val updated = current.copy(customServers = current.customServers + server) + persist(updated) + } + + suspend fun removeServer(server: String) { + val updated = current.copy(customServers = current.customServers - server) + persist(updated) + } + + suspend fun reset() { + persist(NamecoinSettings.DEFAULT) + } + + // ── Internal ─────────────────────────────────────────────────────── + + private suspend fun persist(settings: NamecoinSettings) { + _settings.value = settings + try { + context.sharedPreferencesDataStore.edit { prefs -> + prefs[KEY_ENABLED] = settings.enabled + prefs[KEY_CUSTOM_SERVERS] = + json.encodeToString( + settings.customServers.filter { it.isNotBlank() }, + ) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NamecoinPrefs", "Error writing DataStore: ${e.message}") + } + } + + private suspend fun loadFromDisk(): NamecoinSettings? = + try { + val prefs = context.sharedPreferencesDataStore.data.first() + val enabled = prefs[KEY_ENABLED] ?: true + val serversJson = prefs[KEY_CUSTOM_SERVERS] + val servers = + if (serversJson != null) { + try { + json.decodeFromString>(serversJson) + } catch (_: Exception) { + emptyList() + } + } else { + emptyList() + } + NamecoinSettings(enabled = enabled, customServers = servers) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NamecoinPrefs", "Error reading DataStore: ${e.message}") + null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt new file mode 100644 index 0000000000..c453622ee5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.namecoin + +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import kotlinx.serialization.Serializable + +/** + * Immutable data class representing the current Namecoin resolution config. + * + * When custom servers are configured, they are used EXCLUSIVELY and the + * hardcoded defaults are ignored. This gives privacy-conscious users full + * control over which ElectrumX servers observe their name lookups. + */ +@Serializable +data class NamecoinSettings( + /** Whether Namecoin resolution is enabled at all. */ + val enabled: Boolean = true, + /** + * Custom ElectrumX servers. When non-empty, these replace the defaults. + * + * Each entry is `host:port` (TLS) or `host:port:tcp` (plaintext). + */ + val customServers: List = emptyList(), +) { + /** True when the user has configured at least one custom server. */ + val hasCustomServers: Boolean get() = customServers.isNotEmpty() + + /** + * Convert to [ElectrumxServer] instances used by the resolver. + * Returns `null` when no valid custom servers are configured (use defaults). + */ + fun toElectrumxServers(): List? { + if (customServers.isEmpty()) return null + return customServers + .mapNotNull { parseServerString(it) } + .ifEmpty { null } + } + + companion object { + val DEFAULT = NamecoinSettings() + + /** + * Parse `host:port` or `host:port:tcp` into an [ElectrumxServer]. + * + * TLS is the default protocol. Append `:tcp` for plaintext + * (useful for `.onion` addresses and local servers). + * + * `.onion` addresses automatically get `trustAllCerts = true` + * since certificate verification is meaningless over Tor. + */ + fun parseServerString(s: String): ElectrumxServer? { + val parts = s.trim().split(":") + if (parts.size < 2) return null + val host = parts[0].trim() + val port = parts[1].trim().toIntOrNull() ?: return null + if (host.isEmpty() || port <= 0 || port > 65535) return null + val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp" + val isOnion = host.endsWith(".onion") + return ElectrumxServer( + host = host, + port = port, + useSsl = useSsl, + trustAllCerts = isOnion || !useSsl, + ) + } + + /** + * Format an [ElectrumxServer] back to the `host:port[:tcp]` string form. + */ + fun formatServerString(server: ElectrumxServer): String { + val base = "${server.host}:${server.port}" + return if (server.useSsl) base else "$base:tcp" + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index f032e18250..b06386b20d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -171,7 +171,7 @@ fun AppNavigation( composableFromEnd { AllSettingsScreen(accountViewModel, nav) } composableFromEnd { AccountBackupScreen(accountViewModel, nav) } composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } - composableFromEnd { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) } + composableFromEnd { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, Amethyst.instance.namecoinPrefs, nav) } composableFromEnd { BookmarkListScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { SettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt index 5a21fcb121..5d65ae4f0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt @@ -21,29 +21,38 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsSection import com.vitorpamplona.amethyst.ui.tor.PrivacySettingsBody import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreen( torSettingsFlow: TorSettingsFlow, + namecoinPrefs: NamecoinSharedPreferences, nav: INav, ) { val dialogViewModel = viewModel() @@ -58,16 +67,20 @@ fun PrivacyOptionsScreen( torSettings } - PrivacyOptionsScreenContents(dialogViewModel, onPost = torSettingsFlow::update, nav) + PrivacyOptionsScreenContents(dialogViewModel, namecoinPrefs, onPost = torSettingsFlow::update, nav) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreenContents( dialogViewModel: TorDialogViewModel, + namecoinPrefs: NamecoinSharedPreferences, onPost: (TorSettings) -> Unit, nav: INav, ) { + val namecoinSettings by namecoinPrefs.settings.collectAsState() + val scope = rememberCoroutineScope() + Scaffold( topBar = { SavingTopBar( @@ -91,6 +104,26 @@ fun PrivacyOptionsScreenContents( ).padding(horizontal = 10.dp), ) { PrivacySettingsBody(dialogViewModel) + + Spacer(Modifier.height(16.dp)) + + NamecoinSettingsSection( + settings = namecoinSettings, + onToggleEnabled = { enabled -> + scope.launch { namecoinPrefs.setEnabled(enabled) } + }, + onAddServer = { server -> + scope.launch { namecoinPrefs.addServer(server) } + }, + onRemoveServer = { server -> + scope.launch { namecoinPrefs.removeServer(server) } + }, + onReset = { + scope.launch { namecoinPrefs.reset() } + }, + ) + + Spacer(Modifier.height(16.dp)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt new file mode 100644 index 0000000000..9838b9fcd0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -0,0 +1,416 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS + +/** + * Complete settings section for Namecoin ElectrumX server configuration. + * + * Designed to sit in the Privacy / Settings screen alongside existing + * Tor settings. + * + * @param settings Current [NamecoinSettings] state + * @param onToggleEnabled Called when user toggles the master switch + * @param onAddServer Called with `host:port[:tcp]` when user adds a server + * @param onRemoveServer Called with the server string to remove + * @param onReset Called when user resets to defaults + */ +@Composable +fun NamecoinSettingsSection( + settings: NamecoinSettings, + onToggleEnabled: (Boolean) -> Unit, + onAddServer: (String) -> Unit, + onRemoveServer: (String) -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + // ── Section header ───────────────────────────────────────── + SectionHeader(enabled = settings.enabled, onToggle = onToggleEnabled) + + AnimatedVisibility( + visible = settings.enabled, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + Spacer(Modifier.height(12.dp)) + + // ── Explanation ───────────────────────────────────── + Text( + "Namecoin names (.bit, d/, id/) are resolved via ElectrumX servers. " + + "By default, public community servers are used. " + + "For maximum privacy, add your own server below — when custom " + + "servers are set, the defaults are completely ignored.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + // ── Active servers display ───────────────────────── + ActiveServersDisplay(settings = settings) + + Spacer(Modifier.height(12.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(12.dp)) + + // ── Custom servers list ──────────────────────────── + CustomServersList( + servers = settings.customServers, + onRemove = onRemoveServer, + ) + + // ── Add server input ─────────────────────────────── + AddServerInput(onAdd = onAddServer) + + Spacer(Modifier.height(8.dp)) + + // ── Reset button ─────────────────────────────────── + if (settings.hasCustomServers) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onReset) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Reset to defaults") + } + } + } + } + } + } + } +} + +// ── Sub-composables ──────────────────────────────────────────────────── + +@Composable +private fun SectionHeader( + enabled: Boolean, + onToggle: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Lock, + contentDescription = null, + tint = Color(0xFF4A90D9), // Namecoin blue + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(10.dp)) + Column { + Text( + "Namecoin Resolution", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Blockchain identity lookups (.bit)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = enabled, + onCheckedChange = onToggle, + ) + } +} + +@Composable +private fun ActiveServersDisplay(settings: NamecoinSettings) { + val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + val isCustom = settings.hasCustomServers + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Active servers", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + if (isCustom) { + Text( + "CUSTOM", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFF4A90D9), + modifier = + Modifier + .background( + Color(0xFF4A90D9).copy(alpha = 0.1f), + RoundedCornerShape(4.dp), + ).padding(horizontal = 6.dp, vertical = 2.dp), + ) + } else { + Text( + "DEFAULT", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(6.dp)) + servers.forEach { server -> + ServerRow( + displayText = + "${server.host}:${server.port}" + + if (!server.useSsl) " (tcp)" else " (tls)", + isActive = true, + ) + } + } +} + +@Composable +private fun CustomServersList( + servers: List, + onRemove: (String) -> Unit, +) { + if (servers.isEmpty()) { + Text( + "No custom servers configured", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.padding(vertical = 4.dp), + ) + } else { + Text( + "Custom servers (used exclusively)", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 4.dp), + ) + servers.forEach { server -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = server, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { onRemove(server) }, + modifier = Modifier.size(28.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Remove server", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun AddServerInput(onAdd: (String) -> Unit) { + var input by rememberSaveable { mutableStateOf("") } + var validationError by remember { mutableStateOf(null) } + val kb = LocalSoftwareKeyboardController.current + + fun tryAdd() { + val trimmed = input.trim() + if (trimmed.isBlank()) { + validationError = "Enter a server address" + return + } + val parsed = NamecoinSettings.parseServerString(trimmed) + if (parsed == null) { + validationError = "Invalid format. Use host:port or host:port:tcp" + return + } + validationError = null + onAdd(trimmed) + input = "" + kb?.hide() + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + OutlinedTextField( + value = input, + onValueChange = { + input = it + validationError = null + }, + label = { Text("Add ElectrumX server") }, + placeholder = { Text("host:port or host:port:tcp") }, + singleLine = true, + isError = validationError != null, + supportingText = + validationError?.let { err -> + { Text(err, color = MaterialTheme.colorScheme.error) } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { tryAdd() }), + textStyle = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { tryAdd() }, + modifier = + Modifier + .padding(top = 8.dp) + .size(40.dp) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + RoundedCornerShape(8.dp), + ), + ) { + Icon( + Icons.Default.Add, + contentDescription = "Add server", + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun ServerRow( + displayText: String, + isActive: Boolean, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "•", + fontSize = 10.sp, + color = + if (isActive) { + Color(0xFF2E8B57) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(end = 6.dp), + ) + Text( + text = displayText, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt new file mode 100644 index 0000000000..4eb495af60 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.namecoin + +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NamecoinSettingsTest { + // ── Server string parsing ────────────────────────────────────────── + + @Test + fun `parses host colon port as TLS`() { + val s = NamecoinSettings.parseServerString("example.com:50006") + assertNotNull(s) + assertEquals("example.com", s!!.host) + assertEquals(50006, s.port) + assertTrue(s.useSsl) + } + + @Test + fun `parses host colon port colon tcp as plaintext`() { + val s = NamecoinSettings.parseServerString("example.com:50001:tcp") + assertNotNull(s) + assertEquals("example.com", s!!.host) + assertEquals(50001, s.port) + assertFalse(s.useSsl) + } + + @Test + fun `parses onion address`() { + val s = NamecoinSettings.parseServerString("abc123def.onion:50001:tcp") + assertNotNull(s) + assertEquals("abc123def.onion", s!!.host) + assertEquals(50001, s.port) + assertFalse(s.useSsl) + assertTrue(s.trustAllCerts) + } + + @Test + fun `trims whitespace`() { + val s = NamecoinSettings.parseServerString(" example.com : 50006 ") + assertNotNull(s) + assertEquals("example.com", s!!.host) + assertEquals(50006, s.port) + } + + @Test + fun `rejects empty host`() { + assertNull(NamecoinSettings.parseServerString(":50006")) + } + + @Test + fun `rejects invalid port`() { + assertNull(NamecoinSettings.parseServerString("example.com:abc")) + assertNull(NamecoinSettings.parseServerString("example.com:0")) + assertNull(NamecoinSettings.parseServerString("example.com:99999")) + } + + @Test + fun `rejects no port`() { + assertNull(NamecoinSettings.parseServerString("example.com")) + } + + // ── Format round-trip ────────────────────────────────────────────── + + @Test + fun `formats TLS server without suffix`() { + val server = ElectrumxServer("example.com", 50006, true) + assertEquals("example.com:50006", NamecoinSettings.formatServerString(server)) + } + + @Test + fun `formats TCP server with tcp suffix`() { + val server = ElectrumxServer("example.com", 50001, false) + assertEquals("example.com:50001:tcp", NamecoinSettings.formatServerString(server)) + } + + @Test + fun `round-trips server string through parse and format`() { + val original = "myserver.onion:50001:tcp" + val parsed = NamecoinSettings.parseServerString(original)!! + val formatted = NamecoinSettings.formatServerString(parsed) + assertEquals(original, formatted) + } + + // ── toElectrumxServers ───────────────────────────────────────────── + + @Test + fun `returns null when no custom servers`() { + val settings = NamecoinSettings(customServers = emptyList()) + assertNull(settings.toElectrumxServers()) + } + + @Test + fun `returns parsed list for valid custom servers`() { + val settings = + NamecoinSettings( + customServers = + listOf( + "server1.com:50006", + "server2.onion:50001:tcp", + ), + ) + val servers = settings.toElectrumxServers() + assertNotNull(servers) + assertEquals(2, servers!!.size) + assertEquals("server1.com", servers[0].host) + assertTrue(servers[0].useSsl) + assertEquals("server2.onion", servers[1].host) + assertFalse(servers[1].useSsl) + assertTrue(servers[1].trustAllCerts) + } + + @Test + fun `skips invalid entries in custom server list`() { + val settings = + NamecoinSettings( + customServers = + listOf( + "valid.com:50006", + "invalid", // no port + "also-invalid:abc", // non-numeric port + ), + ) + val servers = settings.toElectrumxServers() + assertNotNull(servers) + assertEquals(1, servers!!.size) + assertEquals("valid.com", servers[0].host) + } + + @Test + fun `returns null when all custom servers are invalid`() { + val settings = NamecoinSettings(customServers = listOf("bad", "also-bad")) + assertNull(settings.toElectrumxServers()) + } + + // ── hasCustomServers flag ────────────────────────────────────────── + + @Test + fun `hasCustomServers is false when empty`() { + assertFalse(NamecoinSettings().hasCustomServers) + } + + @Test + fun `hasCustomServers is true when populated`() { + assertTrue(NamecoinSettings(customServers = listOf("x:1")).hasCustomServers) + } + + // ── Default settings ─────────────────────────────────────────────── + + @Test + fun `default settings are enabled with no custom servers`() { + val d = NamecoinSettings.DEFAULT + assertTrue(d.enabled) + assertTrue(d.customServers.isEmpty()) + assertFalse(d.hasCustomServers) + } +} From 39393d328b12004aaececf078ab23bd37beb4d2c Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 9 Mar 2026 07:49:58 +0200 Subject: [PATCH 3/4] fix(quartz): fix NIP-44 key mutation and NIP-46 connect response handling Two bugs in quartz's NIP-46 remote signer: 1. FixedKey.getEncoded() returned the raw byte array reference. javax.crypto.Mac zeroes the key via destroy() after HMAC computation, corrupting the NIP-44 saltPrefix ("nip44-v2") after the first computeConversationKey call. All subsequent NIP-44 decryptions with different key pairs fail with Invalid Mac. Fix: return key.copyOf() instead of key. 2. NostrSignerRemote.connect() only handled pubkey responses. Amber (and other signers) may respond with "ack" or "already connected" error. Added ConnectResponse parser that maps these to success and falls back to getPublicKey() to retrieve the actual pubkey. Co-Authored-By: Claude Opus 4.6 --- .../signer/ConnectResponse.kt | 53 ++++ .../signer/NostrSignerRemote.kt | 18 +- .../nip46RemoteSigner/signer/SignerResult.kt | 10 + .../quartz/nip44Encryption/Nip44v2JvmTest.kt | 103 +++++++ .../signer/ResponseParserTest.kt | 283 ++++++++++++++++++ .../quartz/utils/mac/FixedKey.kt | 2 +- 6 files changed, 463 insertions(+), 6 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt new file mode 100644 index 0000000000..32923a56ac --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner.signer + +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey + +class ConnectResponse { + companion object { + fun parse(response: BunkerResponse): SignerResult.RequestAddressed = + when (response) { + is BunkerResponsePublicKey -> { + SignerResult.RequestAddressed.Successful(ConnectResult.PubKey(response.pubkey)) + } + + is BunkerResponseAck -> { + SignerResult.RequestAddressed.Successful(ConnectResult.Ack) + } + + is BunkerResponseError -> { + if (response.error?.contains("already connected", ignoreCase = true) == true) { + SignerResult.RequestAddressed.Successful(ConnectResult.AlreadyConnected) + } else { + SignerResult.RequestAddressed.Rejected() + } + } + + else -> { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index 66810e41fa..cd2409655f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -230,14 +230,22 @@ class NostrSignerRemote( secret = secret, ) }, - parser = PubKeyResponse::parse, + parser = ConnectResponse::parse, ) - if (result is SignerResult.RequestAddressed.Successful) { - return result.result.pubkey - } + return when (result) { + is SignerResult.RequestAddressed.Successful -> { + when (val r = result.result) { + is ConnectResult.PubKey -> r.pubkey + is ConnectResult.Ack -> getPublicKey() + is ConnectResult.AlreadyConnected -> getPublicKey() + } + } - throw convertExceptions("Could not connect", result) + else -> { + throw convertExceptions("Could not connect", result) + } + } } suspend fun getPublicKey(): HexKey { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt index cb7927d10d..54f38f148c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt @@ -67,3 +67,13 @@ data class PingResult( data class PublicKeyResult( val pubkey: String, ) : IResult + +sealed interface ConnectResult : IResult { + data class PubKey( + val pubkey: String, + ) : ConnectResult + + data object Ack : ConnectResult + + data object AlreadyConnected : ConnectResult +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt new file mode 100644 index 0000000000..4173f9e221 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip44Encryption + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * NIP-44v2 tests verifying conversation key derivation, ECDH symmetry, + * and encrypt/decrypt round-trips on JVM Desktop. + */ +class Nip44v2JvmTest { + @Test + fun conversationKeyFromSpecVector1() { + val nip44v2 = Nip44v2() + val convKey = + nip44v2.getConversationKey( + "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268".hexToByteArray(), + "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133".hexToByteArray(), + ) + assertEquals( + "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1", + convKey.toHexKey(), + ) + } + + @Test + fun conversationKeyFromSpecVector2() { + val nip44v2 = Nip44v2() + val convKey = + nip44v2.getConversationKey( + "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e".hexToByteArray(), + "03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800".hexToByteArray(), + ) + assertEquals( + "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b", + convKey.toHexKey(), + ) + } + + @Test + fun conversationKeyIsSymmetric() { + val nip44v2 = Nip44v2() + val privA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() + val privB = "65f039136f8da8d3e87b4818746b53318d5481e24b2673f162815144223a0b5a".hexToByteArray() + val pubA = KeyPair(privA).pubKey + val pubB = KeyPair(privB).pubKey + + val convAB = nip44v2.getConversationKey(privA, pubB) + val convBA = nip44v2.getConversationKey(privB, pubA) + + assertEquals(convAB.toHexKey(), convBA.toHexKey()) + } + + @Test + fun encryptDecryptRoundTrip() { + val nip44v2 = Nip44v2() + val privA = "0000000000000000000000000000000000000000000000000000000000000001".hexToByteArray() + val privB = "0000000000000000000000000000000000000000000000000000000000000002".hexToByteArray() + val pubA = KeyPair(privA).pubKey + val pubB = KeyPair(privB).pubKey + + val encrypted = nip44v2.encrypt("hello world", privA, pubB) + val decrypted = nip44v2.decrypt(encrypted, privB, pubA) + assertEquals("hello world", decrypted) + } + + @Test + fun crossKeyPairDecrypt() { + val nip44v2 = Nip44v2() + val ephemeralPriv = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".hexToByteArray() + val signerPriv = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".hexToByteArray() + val ephemeralPub = KeyPair(ephemeralPriv).pubKey + val signerPub = KeyPair(signerPriv).pubKey + + val request = """{"id":"test","method":"connect","params":["deadbeef"]}""" + val encryptedRequest = nip44v2.encrypt(request, ephemeralPriv, signerPub) + + val decryptedRequest = nip44v2.decrypt(encryptedRequest, signerPriv, ephemeralPub) + assertEquals(request, decryptedRequest) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt new file mode 100644 index 0000000000..2f0785dd56 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner.signer + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ResponseParserTest { + // --- ConnectResponse --- + + @Test + fun connectParsePubKey() { + val hex = "a".repeat(64) + val response = BunkerResponsePublicKey("req-0", hex) + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + assertEquals(hex, (result.result as ConnectResult.PubKey).pubkey) + } + + @Test + fun connectParseAck() { + val response = BunkerResponseAck("req-0") + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseAlreadyConnected() { + val response = BunkerResponseError("req-0", "already connected") + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseAlreadyConnectedCaseInsensitive() { + val response = BunkerResponseError("req-0", "Already Connected") + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseRealError() { + val response = BunkerResponseError("req-0", "unauthorized") + val result = ConnectResponse.parse(response) + assertIs>(result) + } + + @Test + fun connectParseUnexpected() { + val response = BunkerResponsePong("req-0") + val result = ConnectResponse.parse(response) + assertIs>(result) + } + + // --- PingResponse --- + + @Test + fun pingParsePong() { + val response = BunkerResponsePong("req-1") + val result = PingResponse.parse(response) + assertIs>(result) + assertEquals("req-1", result.result.pong) + } + + @Test + fun pingParseError() { + val response = BunkerResponseError("req-1", "not allowed") + val result = PingResponse.parse(response) + assertIs>(result) + } + + @Test + fun pingParseUnexpected() { + val response = BunkerResponseAck("req-1") + val result = PingResponse.parse(response) + assertIs>(result) + } + + // --- PubKeyResponse --- + + @Test + fun pubKeyParseSuccess() { + val hex = "a".repeat(64) + val response = BunkerResponsePublicKey("req-2", hex) + val result = PubKeyResponse.parse(response) + assertIs>(result) + assertEquals(hex, result.result.pubkey) + } + + @Test + fun pubKeyParseError() { + val response = BunkerResponseError("req-2", "denied") + val result = PubKeyResponse.parse(response) + assertIs>(result) + } + + @Test + fun pubKeyParseUnexpected() { + val response = BunkerResponseAck("req-2") + val result = PubKeyResponse.parse(response) + assertIs>(result) + } + + // --- SignResponse --- + + @Test + fun signParseValidEvent() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val event: Event = + signer.sign( + createdAt = 1234L, + kind = 1, + tags = emptyArray(), + content = "hello", + ) + val response = BunkerResponseEvent("req-3", event) + val result = SignResponse.parse(response) + assertIs>(result) + assertEquals(event.id, result.result.event.id) + } + + @Test + fun signParseInvalidSignature() { + val event = + Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + kind = 1, + tags = emptyArray(), + content = "hello", + sig = "c".repeat(128), + ) + val response = BunkerResponseEvent("req-3", event) + val result = SignResponse.parse(response) + assertIs>(result) + } + + @Test + fun signParseError() { + val response = BunkerResponseError("req-3", "denied") + val result = SignResponse.parse(response) + assertIs>(result) + } + + @Test + fun signParseUnexpected() { + val response = BunkerResponseAck("req-3") + val result = SignResponse.parse(response) + assertIs>(result) + } + + // --- Nip04EncryptResponse --- + + @Test + fun nip04EncryptParseSuccess() { + val response = BunkerResponseEncrypt("req-4", "ciphertext-data") + val result = Nip04EncryptResponse.parse(response) + assertIs>(result) + assertEquals("ciphertext-data", result.result.ciphertext) + } + + @Test + fun nip04EncryptParseError() { + val response = BunkerResponseError("req-4", "fail") + val result = Nip04EncryptResponse.parse(response) + assertIs>(result) + } + + @Test + fun nip04EncryptParseUnexpected() { + val response = BunkerResponsePong("req-4") + val result = Nip04EncryptResponse.parse(response) + assertIs>(result) + } + + // --- Nip04DecryptResponse --- + + @Test + fun nip04DecryptParseSuccess() { + val response = BunkerResponseDecrypt("req-5", "plain-text") + val result = Nip04DecryptResponse.parse(response) + assertIs>(result) + assertEquals("plain-text", result.result.plaintext) + } + + @Test + fun nip04DecryptParseError() { + val response = BunkerResponseError("req-5", "fail") + val result = Nip04DecryptResponse.parse(response) + assertIs>(result) + } + + @Test + fun nip04DecryptParseUnexpected() { + val response = BunkerResponsePong("req-5") + val result = Nip04DecryptResponse.parse(response) + assertIs>(result) + } + + // --- Nip44EncryptResponse --- + + @Test + fun nip44EncryptParseSuccess() { + val response = BunkerResponseEncrypt("req-6", "nip44-cipher") + val result = Nip44EncryptResponse.parse(response) + assertIs>(result) + assertEquals("nip44-cipher", result.result.ciphertext) + } + + @Test + fun nip44EncryptParseError() { + val response = BunkerResponseError("req-6", "fail") + val result = Nip44EncryptResponse.parse(response) + assertIs>(result) + } + + @Test + fun nip44EncryptParseUnexpected() { + val response = BunkerResponseAck("req-6") + val result = Nip44EncryptResponse.parse(response) + assertIs>(result) + } + + // --- Nip44DecryptResponse --- + + @Test + fun nip44DecryptParseSuccess() { + val response = BunkerResponseDecrypt("req-7", "nip44-plain") + val result = Nip44DecryptResponse.parse(response) + assertIs>(result) + assertEquals("nip44-plain", result.result.plaintext) + } + + @Test + fun nip44DecryptParseError() { + val response = BunkerResponseError("req-7", "fail") + val result = Nip44DecryptResponse.parse(response) + assertIs>(result) + } + + @Test + fun nip44DecryptParseUnexpected() { + val response = BunkerResponseAck("req-7") + val result = Nip44DecryptResponse.parse(response) + assertIs>(result) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/mac/FixedKey.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/mac/FixedKey.kt index 5c884998c2..9d875956b6 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/mac/FixedKey.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/mac/FixedKey.kt @@ -31,7 +31,7 @@ class FixedKey( ) : SecretKey { override fun getAlgorithm() = algo - override fun getEncoded() = key + override fun getEncoded() = key.copyOf() override fun getFormat() = "RAW" From e56b0d2b836ea6d052c51d2e2be042d3480ee30b Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 10 Mar 2026 07:24:57 +0200 Subject: [PATCH 4/4] fix(quartz): connect() returns Unit, caller calls getPublicKey separately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Amber maintainer: connect never returns a pubkey — response is "ack" or the secret string. Rewrote ConnectResponse.parse() to check result/error fields directly on base BunkerResponse (matching Jackson deserialization). connect() is now a pure protocol handshake; callers call getPublicKey() separately as a domain concern. Co-Authored-By: Claude Opus 4.6 --- .../signer/ConnectResponse.kt | 36 +++++++------------ .../signer/NostrSignerRemote.kt | 18 +++------- .../nip46RemoteSigner/signer/SignerResult.kt | 4 --- .../signer/ResponseParserTest.kt | 33 ++++++++--------- 4 files changed, 35 insertions(+), 56 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt index 32923a56ac..a6583a80b0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt @@ -21,33 +21,23 @@ package com.vitorpamplona.quartz.nip46RemoteSigner.signer import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey class ConnectResponse { companion object { - fun parse(response: BunkerResponse): SignerResult.RequestAddressed = - when (response) { - is BunkerResponsePublicKey -> { - SignerResult.RequestAddressed.Successful(ConnectResult.PubKey(response.pubkey)) - } - - is BunkerResponseAck -> { - SignerResult.RequestAddressed.Successful(ConnectResult.Ack) - } - - is BunkerResponseError -> { - if (response.error?.contains("already connected", ignoreCase = true) == true) { - SignerResult.RequestAddressed.Successful(ConnectResult.AlreadyConnected) - } else { - SignerResult.RequestAddressed.Rejected() - } - } - - else -> { - SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + fun parse(response: BunkerResponse): SignerResult.RequestAddressed { + if (response.error != null) { + return if (response.error.contains("already connected", ignoreCase = true)) { + SignerResult.RequestAddressed.Successful(ConnectResult.AlreadyConnected) + } else { + SignerResult.RequestAddressed.Rejected() } } + + if (response.result != null) { + return SignerResult.RequestAddressed.Successful(ConnectResult.Ack) + } + + return SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index cd2409655f..fb2e344a8d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -220,7 +220,7 @@ class NostrSignerRemote( throw convertExceptions("Could not ping", result) } - suspend fun connect(): HexKey { + suspend fun connect() { val result = manager.launchWaitAndParse( bunkerRequestBuilder = { @@ -233,19 +233,11 @@ class NostrSignerRemote( parser = ConnectResponse::parse, ) - return when (result) { - is SignerResult.RequestAddressed.Successful -> { - when (val r = result.result) { - is ConnectResult.PubKey -> r.pubkey - is ConnectResult.Ack -> getPublicKey() - is ConnectResult.AlreadyConnected -> getPublicKey() - } - } - - else -> { - throw convertExceptions("Could not connect", result) - } + if (result is SignerResult.RequestAddressed.Successful) { + return } + + throw convertExceptions("Could not connect", result) } suspend fun getPublicKey(): HexKey { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt index 54f38f148c..a0cba9eb9a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt @@ -69,10 +69,6 @@ data class PublicKeyResult( ) : IResult sealed interface ConnectResult : IResult { - data class PubKey( - val pubkey: String, - ) : ConnectResult - data object Ack : ConnectResult data object AlreadyConnected : ConnectResult diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt index 2f0785dd56..af0e62c21e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip46RemoteSigner.signer import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt @@ -37,20 +38,20 @@ import kotlin.test.assertIs class ResponseParserTest { // --- ConnectResponse --- - - @Test - fun connectParsePubKey() { - val hex = "a".repeat(64) - val response = BunkerResponsePublicKey("req-0", hex) - val result = ConnectResponse.parse(response) - assertIs>(result) - assertIs(result.result) - assertEquals(hex, (result.result as ConnectResult.PubKey).pubkey) - } + // connect never returns a pubkey — it's "ack" or the secret string. + // Tests use base BunkerResponse to match production deserialization behavior. @Test fun connectParseAck() { - val response = BunkerResponseAck("req-0") + val response = BunkerResponse("req-0", "ack", null) + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseSecret() { + val response = BunkerResponse("req-0", "my-secret-token", null) val result = ConnectResponse.parse(response) assertIs>(result) assertIs(result.result) @@ -58,7 +59,7 @@ class ResponseParserTest { @Test fun connectParseAlreadyConnected() { - val response = BunkerResponseError("req-0", "already connected") + val response = BunkerResponse("req-0", null, "already connected") val result = ConnectResponse.parse(response) assertIs>(result) assertIs(result.result) @@ -66,7 +67,7 @@ class ResponseParserTest { @Test fun connectParseAlreadyConnectedCaseInsensitive() { - val response = BunkerResponseError("req-0", "Already Connected") + val response = BunkerResponse("req-0", null, "Already Connected") val result = ConnectResponse.parse(response) assertIs>(result) assertIs(result.result) @@ -74,14 +75,14 @@ class ResponseParserTest { @Test fun connectParseRealError() { - val response = BunkerResponseError("req-0", "unauthorized") + val response = BunkerResponse("req-0", null, "unauthorized") val result = ConnectResponse.parse(response) assertIs>(result) } @Test - fun connectParseUnexpected() { - val response = BunkerResponsePong("req-0") + fun connectParseNoResultNoError() { + val response = BunkerResponse("req-0", null, null) val result = ConnectResponse.parse(response) assertIs>(result) }