Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: fix(quartz): connect() returns Unit, caller calls getPublicKey separately fix(quartz): fix NIP-44 key mutation and NIP-46 connect response handling feat: add custom ElectrumX server settings for Namecoin resolution feat: import follow list with Namecoin resolution during signup
This commit is contained in:
@@ -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)
|
||||
|
||||
+143
@@ -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<NamecoinSettings> = _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<ElectrumxServer>?
|
||||
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<List<String>>(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
|
||||
}
|
||||
}
|
||||
+257
@@ -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<FollowEntry>,
|
||||
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<List<String>>,
|
||||
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<String>,
|
||||
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<Kind3EventData?>()
|
||||
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,
|
||||
)
|
||||
+12
@@ -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.
|
||||
*
|
||||
|
||||
+94
@@ -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<String> = 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<ElectrumxServer>? {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ fun AppNavigation(
|
||||
composableFromEnd<Route.AllSettings> { AllSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) }
|
||||
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, Amethyst.instance.namecoinPrefs, nav) }
|
||||
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -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<Filter>?,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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.
|
||||
|
||||
+34
-1
@@ -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<TorDialogViewModel>()
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+416
@@ -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<String>,
|
||||
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<String?>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+545
@@ -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<FollowEntry>) -> 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<FollowEntry>) -> 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
|
||||
}
|
||||
}
|
||||
+174
@@ -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<FollowEntry>,
|
||||
val selected: Set<String>,
|
||||
/** 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>(ImportFollowState.Idle)
|
||||
val state: StateFlow<ImportFollowState> = _state.asStateFlow()
|
||||
|
||||
private var fetchEventFn: (suspend (Int, String, Int, (Kind3EventData) -> Unit) -> AutoCloseable?)? = null
|
||||
private var resolveNip05Fn: (suspend (String) -> String?)? = null
|
||||
private var relayUrls: List<String> =
|
||||
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<String>? = 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<FollowEntry>) -> 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
|
||||
}
|
||||
}
|
||||
+268
@@ -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"))
|
||||
}
|
||||
}
|
||||
+181
@@ -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)
|
||||
}
|
||||
}
|
||||
+75
@@ -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.
|
||||
*
|
||||
|
||||
+43
@@ -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<ConnectResult> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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<PublicKeyResult>) {
|
||||
return result.result.pubkey
|
||||
if (result is SignerResult.RequestAddressed.Successful<ConnectResult>) {
|
||||
return
|
||||
}
|
||||
|
||||
throw convertExceptions("Could not connect", result)
|
||||
|
||||
+6
@@ -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
|
||||
}
|
||||
|
||||
+103
@@ -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)
|
||||
}
|
||||
}
|
||||
+284
@@ -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<SignerResult.RequestAddressed.Successful<ConnectResult>>(result)
|
||||
assertIs<ConnectResult.Ack>(result.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectParseSecret() {
|
||||
val response = BunkerResponse("req-0", "my-secret-token", null)
|
||||
val result = ConnectResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<ConnectResult>>(result)
|
||||
assertIs<ConnectResult.Ack>(result.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectParseAlreadyConnected() {
|
||||
val response = BunkerResponse("req-0", null, "already connected")
|
||||
val result = ConnectResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<ConnectResult>>(result)
|
||||
assertIs<ConnectResult.AlreadyConnected>(result.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectParseAlreadyConnectedCaseInsensitive() {
|
||||
val response = BunkerResponse("req-0", null, "Already Connected")
|
||||
val result = ConnectResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<ConnectResult>>(result)
|
||||
assertIs<ConnectResult.AlreadyConnected>(result.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectParseRealError() {
|
||||
val response = BunkerResponse("req-0", null, "unauthorized")
|
||||
val result = ConnectResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Rejected<ConnectResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectParseNoResultNoError() {
|
||||
val response = BunkerResponse("req-0", null, null)
|
||||
val result = ConnectResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<ConnectResult>>(result)
|
||||
}
|
||||
|
||||
// --- PingResponse ---
|
||||
|
||||
@Test
|
||||
fun pingParsePong() {
|
||||
val response = BunkerResponsePong("req-1")
|
||||
val result = PingResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<PingResult>>(result)
|
||||
assertEquals("req-1", result.result.pong)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pingParseError() {
|
||||
val response = BunkerResponseError("req-1", "not allowed")
|
||||
val result = PingResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Rejected<PingResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pingParseUnexpected() {
|
||||
val response = BunkerResponseAck("req-1")
|
||||
val result = PingResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<PingResult>>(result)
|
||||
}
|
||||
|
||||
// --- PubKeyResponse ---
|
||||
|
||||
@Test
|
||||
fun pubKeyParseSuccess() {
|
||||
val hex = "a".repeat(64)
|
||||
val response = BunkerResponsePublicKey("req-2", hex)
|
||||
val result = PubKeyResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<PublicKeyResult>>(result)
|
||||
assertEquals(hex, result.result.pubkey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pubKeyParseError() {
|
||||
val response = BunkerResponseError("req-2", "denied")
|
||||
val result = PubKeyResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Rejected<PublicKeyResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pubKeyParseUnexpected() {
|
||||
val response = BunkerResponseAck("req-2")
|
||||
val result = PubKeyResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<PublicKeyResult>>(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<SignerResult.RequestAddressed.Successful<SignResult>>(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<SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent<SignResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signParseError() {
|
||||
val response = BunkerResponseError("req-3", "denied")
|
||||
val result = SignResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Rejected<SignResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signParseUnexpected() {
|
||||
val response = BunkerResponseAck("req-3")
|
||||
val result = SignResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<SignResult>>(result)
|
||||
}
|
||||
|
||||
// --- Nip04EncryptResponse ---
|
||||
|
||||
@Test
|
||||
fun nip04EncryptParseSuccess() {
|
||||
val response = BunkerResponseEncrypt("req-4", "ciphertext-data")
|
||||
val result = Nip04EncryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<EncryptionResult>>(result)
|
||||
assertEquals("ciphertext-data", result.result.ciphertext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip04EncryptParseError() {
|
||||
val response = BunkerResponseError("req-4", "fail")
|
||||
val result = Nip04EncryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Rejected<EncryptionResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip04EncryptParseUnexpected() {
|
||||
val response = BunkerResponsePong("req-4")
|
||||
val result = Nip04EncryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<EncryptionResult>>(result)
|
||||
}
|
||||
|
||||
// --- Nip04DecryptResponse ---
|
||||
|
||||
@Test
|
||||
fun nip04DecryptParseSuccess() {
|
||||
val response = BunkerResponseDecrypt("req-5", "plain-text")
|
||||
val result = Nip04DecryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<DecryptionResult>>(result)
|
||||
assertEquals("plain-text", result.result.plaintext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip04DecryptParseError() {
|
||||
val response = BunkerResponseError("req-5", "fail")
|
||||
val result = Nip04DecryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Rejected<DecryptionResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip04DecryptParseUnexpected() {
|
||||
val response = BunkerResponsePong("req-5")
|
||||
val result = Nip04DecryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<DecryptionResult>>(result)
|
||||
}
|
||||
|
||||
// --- Nip44EncryptResponse ---
|
||||
|
||||
@Test
|
||||
fun nip44EncryptParseSuccess() {
|
||||
val response = BunkerResponseEncrypt("req-6", "nip44-cipher")
|
||||
val result = Nip44EncryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<EncryptionResult>>(result)
|
||||
assertEquals("nip44-cipher", result.result.ciphertext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip44EncryptParseError() {
|
||||
val response = BunkerResponseError("req-6", "fail")
|
||||
val result = Nip44EncryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Rejected<EncryptionResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip44EncryptParseUnexpected() {
|
||||
val response = BunkerResponseAck("req-6")
|
||||
val result = Nip44EncryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<EncryptionResult>>(result)
|
||||
}
|
||||
|
||||
// --- Nip44DecryptResponse ---
|
||||
|
||||
@Test
|
||||
fun nip44DecryptParseSuccess() {
|
||||
val response = BunkerResponseDecrypt("req-7", "nip44-plain")
|
||||
val result = Nip44DecryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Successful<DecryptionResult>>(result)
|
||||
assertEquals("nip44-plain", result.result.plaintext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip44DecryptParseError() {
|
||||
val response = BunkerResponseError("req-7", "fail")
|
||||
val result = Nip44DecryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.Rejected<DecryptionResult>>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip44DecryptParseUnexpected() {
|
||||
val response = BunkerResponseAck("req-7")
|
||||
val result = Nip44DecryptResponse.parse(response)
|
||||
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<DecryptionResult>>(result)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ class FixedKey(
|
||||
) : SecretKey {
|
||||
override fun getAlgorithm() = algo
|
||||
|
||||
override fun getEncoded() = key
|
||||
override fun getEncoded() = key.copyOf()
|
||||
|
||||
override fun getFormat() = "RAW"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user