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/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/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/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 9441298cf4..feabf7a1a3 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 @@ -87,6 +87,7 @@ sealed class AccountState { class LoggedIn( val account: Account, var route: Route? = null, + val isNewAccount: Boolean = false, ) : AccountState() } @@ -183,10 +184,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) + } } } @@ -278,7 +289,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/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/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/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) + } +} 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. * 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..a6583a80b0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt @@ -0,0 +1,43 @@ +/* + * 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 + +class ConnectResponse { + companion object { + 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 66810e41fa..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 = { @@ -230,11 +230,11 @@ class NostrSignerRemote( secret = secret, ) }, - parser = PubKeyResponse::parse, + parser = ConnectResponse::parse, ) - if (result is SignerResult.RequestAddressed.Successful) { - return result.result.pubkey + if (result is SignerResult.RequestAddressed.Successful) { + return } throw convertExceptions("Could not connect", result) 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..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 @@ -67,3 +67,9 @@ data class PingResult( data class PublicKeyResult( val pubkey: String, ) : IResult + +sealed interface ConnectResult : IResult { + 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..af0e62c21e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt @@ -0,0 +1,284 @@ +/* + * 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.BunkerResponse +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 --- + // 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 = 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) + } + + @Test + fun connectParseAlreadyConnected() { + val response = BunkerResponse("req-0", null, "already connected") + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseAlreadyConnectedCaseInsensitive() { + val response = BunkerResponse("req-0", null, "Already Connected") + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseRealError() { + val response = BunkerResponse("req-0", null, "unauthorized") + val result = ConnectResponse.parse(response) + assertIs>(result) + } + + @Test + fun connectParseNoResultNoError() { + val response = BunkerResponse("req-0", null, null) + 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"