diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 280411842..84ce76f89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -34,6 +34,8 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinLookupException import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver @@ -46,8 +48,8 @@ import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn @@ -75,28 +77,62 @@ class SearchBarViewModel( val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account) /** - * Resolves Namecoin identifiers (.bit / d/ / id/) via ElectrumX and - * returns the matching [User] from LocalCache, or null. + * Observable state for Namecoin resolution in the search bar. + * Allows the UI to show loading spinners, resolved profiles, and + * specific error messages ("name not found" vs "servers unreachable"). */ - private val namecoinResolvedUser = + sealed class NamecoinSearchState { + data object Idle : NamecoinSearchState() + data object Loading : NamecoinSearchState() + data class Resolved(val user: User) : NamecoinSearchState() + data class NotFound(val name: String) : NamecoinSearchState() + data class Expired(val name: String) : NamecoinSearchState() + data object ServersUnreachable : NamecoinSearchState() + } + + /** + * Resolves Namecoin identifiers (.bit / d/ / id/) via ElectrumX. + * Emits typed state so the UI can show loading, resolved profile, or + * specific error messages. + */ + val namecoinSearchState: StateFlow = searchValueFlow .debounce(400) .distinctUntilChanged() - .filter { NamecoinNameResolver.isNamecoinIdentifier(it) } .mapLatest { term -> + if (!NamecoinNameResolver.isNamecoinIdentifier(term)) { + return@mapLatest NamecoinSearchState.Idle + } try { val result = Amethyst.instance.namecoinResolver.resolve(term) if (result != null) { - LocalCache.getOrCreateUser(result.pubkey) + NamecoinSearchState.Resolved(LocalCache.getOrCreateUser(result.pubkey)) } else { - null + NamecoinSearchState.NotFound(term) } } catch (e: kotlinx.coroutines.CancellationException) { throw e + } catch (e: NamecoinLookupException.NameNotFound) { + NamecoinSearchState.NotFound(e.name) + } catch (e: NamecoinLookupException.NameExpired) { + NamecoinSearchState.Expired(e.name) + } catch (e: NamecoinLookupException.ServersUnreachable) { + NamecoinSearchState.ServersUnreachable } catch (_: Exception) { - null + NamecoinSearchState.ServersUnreachable } }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, WhileSubscribed(5000), NamecoinSearchState.Idle) + + /** Convenience: the resolved user (or null) for combining with local search results. */ + private val namecoinResolvedUser = + namecoinSearchState + .map { state -> + when (state) { + is NamecoinSearchState.Resolved -> state.user + else -> null + } + } .stateIn(viewModelScope, WhileSubscribed(5000), null) val searchResultsUsers = diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index 477b93733..54486b552 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -52,6 +52,48 @@ import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager import javax.net.ssl.X509TrustManager +/** + * Result of an ElectrumX name_show query. + * + * Maps to the JSON fields returned by Namecoin Core / Electrum-NMC: + * { "name": "d/example", "value": "{...}", "txid": "abc...", "height": 12345, ... } + */ +@Serializable +data class NameShowResult( + val name: String, + val value: String, + val txid: String? = null, + val height: Int? = null, + val expiresIn: Int? = null, +) + +/** + * Specific exception types for Namecoin resolution failures. + * Allows callers to distinguish "name doesn't exist" from "servers unreachable". + */ +sealed class NamecoinLookupException(message: String, cause: Throwable? = null) : Exception(message, cause) { + /** The name was queried successfully but does not exist on the blockchain. */ + class NameNotFound(val name: String) : NamecoinLookupException("Name not found: $name") + + /** The name has expired (>36000 blocks since last update). */ + class NameExpired(val name: String) : NamecoinLookupException("Name expired: $name") + + /** All ElectrumX servers were unreachable or returned errors. */ + class ServersUnreachable(val lastError: Throwable? = null) : + NamecoinLookupException("All ElectrumX servers unreachable", lastError) +} + +/** + * Represents a single ElectrumX server endpoint. + */ +data class ElectrumxServer( + val host: String, + val port: Int, + val useSsl: Boolean = true, + /** If true, accept any certificate (self-signed, expired, etc.) */ + val trustAllCerts: Boolean = false, +) + /** * Lightweight, query-only ElectrumX client for Namecoin name resolution. * @@ -126,6 +168,9 @@ class ElectrumXClient( mutex.withLock { try { connectAndQuery(identifier, server) + } catch (e: NamecoinLookupException) { + // Propagate name-not-found and expired — these are definitive answers. + throw e } catch (e: Exception) { // Log but don't crash — callers handle null gracefully. e.printStackTrace() @@ -140,15 +185,31 @@ class ElectrumXClient( * @param identifier Full Namecoin name, e.g. "d/example" * @param servers Ordered server list to try; defaults to [DEFAULT_ELECTRUMX_SERVERS] */ + /** + * Try each server in order until one succeeds. + * + * @throws NamecoinLookupException.NameNotFound if the name definitively doesn't exist + * @throws NamecoinLookupException.NameExpired if the name has expired + * @throws NamecoinLookupException.ServersUnreachable if all servers failed with connection errors + */ override suspend fun nameShowWithFallback( identifier: String, servers: List, ): NameShowResult? { + var lastError: Exception? = null for (server in servers) { - val result = nameShow(identifier, server) - if (result != null) return result + try { + val result = nameShow(identifier, server) + if (result != null) return result + } catch (e: NamecoinLookupException.NameNotFound) { + throw e // Definitive answer from blockchain — no point trying other servers + } catch (e: NamecoinLookupException.NameExpired) { + throw e // Definitive answer + } catch (e: Exception) { + lastError = e // Server error — try next server + } } - return null + throw NamecoinLookupException.ServersUnreachable(lastError) } // ── internals ────────────────────────────────────────────────────── @@ -177,7 +238,7 @@ class ElectrumXClient( writer.println(historyReq) val historyResponse = reader.readLine() ?: return null val historyEntries = parseHistoryResponse(historyResponse) ?: return null - if (historyEntries.isEmpty()) return null + if (historyEntries.isEmpty()) throw NamecoinLookupException.NameNotFound(identifier) // 4. Get the latest transaction (last entry = most recent update) val latestEntry = historyEntries.last() @@ -198,7 +259,7 @@ class ElectrumXClient( if (currentHeight != null && height > 0) { val blocksSinceUpdate = currentHeight - height if (blocksSinceUpdate >= NAME_EXPIRE_DEPTH) { - return null // Name has expired + throw NamecoinLookupException.NameExpired(identifier) } }