diff --git a/CLAUDE.md b/CLAUDE.md index da2674e5..810470f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ All three paths converge on `Account.sign()` / encrypt/decrypt methods backed by - `applicationIOScope` — `CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)`, used for all background work - `client: NostrClient` — the Quartz Nostr relay client - `notificationSubscription` — keeps the NIP-46 filter alive in the background -- `profileSubscription` — per-account, throttled one-shot metadata (kind 0) fetch; started/stopped by the composables that display each account via `ProfileSubscriptionEffect` (not app-wide) +- `profileSubscription` — per-account, throttled one-shot metadata fetch; first fetches the user's NIP-65 relay list (kind 10002) and saves it locally, then fetches the metadata (kind 0) from the default profile relays plus the saved user relays; started/stopped by the composables that display each account via `ProfileSubscriptionEffect` (not app-wide) - `isStartingAppState: MutableStateFlow` — set to `true` during `runMigrations()`; code that must wait for startup calls `isStartingAppState.first { !it }` - `settings.killSwitch` — when true, all relays are disconnected; checked before every relay operation diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt index 5d9158de..533dbe4b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt @@ -20,6 +20,7 @@ import com.greenart7c3.nostrsigner.service.TorManager import com.greenart7c3.nostrsigner.ui.parseBiometricsTimeType import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip19Bech32.toNpub @@ -37,6 +38,8 @@ private enum class PrefKeys(val key: String) { PROFILE_URL("profile_url"), LAST_METADATA_UPDATE("last_metadata_update"), LAST_CHECK("last_check"), + USER_RELAYS("user_relays"), + USER_RELAYS_CREATED_AT("user_relays_created_at"), DID_BACKUP("did_backup"), BACKUP_APPLICATIONS("backup_applications"), } @@ -187,6 +190,21 @@ object LocalPreferences { } } + fun getUserRelays(context: Context, npub: String): List = sharedPrefs(context, npub).getStringSet(PrefKeys.USER_RELAYS.key, null)?.mapNotNull { + RelayUrlNormalizer.normalizeOrNull(it) + } ?: emptyList() + + fun getUserRelaysCreatedAt(context: Context, npub: String): Long = sharedPrefs(context, npub).getLong(PrefKeys.USER_RELAYS_CREATED_AT.key, 0) + + fun setUserRelays(context: Context, npub: String, relays: List, createdAt: Long) { + sharedPrefs(context, npub).edit { + apply { + putStringSet(PrefKeys.USER_RELAYS.key, relays.map { it.url }.toSet()) + putLong(PrefKeys.USER_RELAYS_CREATED_AT.key, createdAt) + } + } + } + fun getLastMetadataUpdate(context: Context, npub: String): Long = sharedPrefs(context, npub).getLong(PrefKeys.LAST_METADATA_UPDATE.key, 0) fun setLastMetadataUpdate(context: Context, npub: String, time: Long) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/ProfileSubscription.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/ProfileSubscription.kt index 247c60f5..d7cd6b7a 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/ProfileSubscription.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/ProfileSubscription.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.TimeUtils import java.util.UUID import kotlinx.coroutines.CoroutineScope @@ -51,7 +52,11 @@ class ProfileSubscription( val appContext: Context, val scope: CoroutineScope, ) : RelayConnectionListener { + // hexKey -> subId of the kind-0 metadata subscription private val subIds = mutableMapOf() + + // hexKey -> subId of the kind-10002 relay list subscription that runs before the metadata one + private val relayListSubIds = mutableMapOf() private val relaysPerSubId = mutableMapOf>() private val timeoutJobs = mutableMapOf() @@ -71,10 +76,8 @@ class ProfileSubscription( if (relays != null) { relays.remove(relay.url) if (relays.isEmpty()) { - timeoutJobs.remove(subId)?.cancel() - Amber.instance.intentionalDisconnectTime = System.currentTimeMillis() - client.unsubscribe(subId) - relaysPerSubId.remove(subId) + unsubscribe(subId) + onRelayListSubFinished(subId) } } @@ -87,6 +90,11 @@ class ProfileSubscription( } } if (msg is EventMessage) { + if (this.relayListSubIds.containsValue(msg.subId)) { + if (msg.event.kind == AdvertisedRelayListEvent.KIND && msg.event.verify()) { + (msg.event as? AdvertisedRelayListEvent)?.let { saveUserRelays(it) } + } + } if (this.subIds.containsValue(msg.subId)) { if (msg.event.kind == MetadataEvent.KIND && msg.event.verify()) { val account = accounts[msg.event.pubKey] ?: return @@ -123,6 +131,8 @@ class ProfileSubscription( /** * Starts (or refreshes) the throttled, one-shot metadata fetch for [account]. + * First fetches the user's NIP-65 relay list (kind 10002), saves it locally, then + * fetches the metadata from the default profile relays plus the user's own relays. * Tracks the account so incoming events update its StateFlows; safe to call from * any composable displaying the account. */ @@ -134,9 +144,6 @@ class ProfileSubscription( accounts[account.hexKey] = account - if (!subIds.containsKey(account.hexKey)) { - subIds[account.hexKey] = UUID.randomUUID().toString() - } val shouldFetch = if (interval == ProfileFetchInterval.ALWAYS) { true } else { @@ -147,22 +154,70 @@ class ProfileSubscription( (lastMetaData == 0L || oneDayAgo > lastMetaData) && (lastCheck == 0L || fetchIntervalAgo > lastCheck) } if (shouldFetch) { - val subId = subIds[account.hexKey]!! - val profileFilter = createProfileFilter(account) - relaysPerSubId[subId] = profileFilter.keys.toMutableSet() - client.subscribe(subId, profileFilter) - timeoutJobs[subId] = scope.launch { - delay(EOSE_TIMEOUT_MS) - if (relaysPerSubId.containsKey(subId)) { - Amber.instance.intentionalDisconnectTime = System.currentTimeMillis() - client.unsubscribe(subId) - relaysPerSubId.remove(subId) - timeoutJobs.remove(subId) - } + subscribeToUserRelayList(account) + } + } + + private fun subscribeToUserRelayList(account: Account) { + val subId = relayListSubIds.getOrPut(account.hexKey) { UUID.randomUUID().toString() } + val relayListFilter = createRelayListFilter(account) + timeoutJobs.remove(subId)?.cancel() + relaysPerSubId[subId] = relayListFilter.keys.toMutableSet() + client.subscribe(subId, relayListFilter) + timeoutJobs[subId] = scope.launch { + delay(EOSE_TIMEOUT_MS) + if (relaysPerSubId.containsKey(subId)) { + unsubscribe(subId) + // still fetch the profile with whatever relay list we have saved + onRelayListSubFinished(subId) } } } + private fun subscribeToProfile(account: Account) { + val subId = subIds.getOrPut(account.hexKey) { UUID.randomUUID().toString() } + val profileFilter = createProfileFilter(account) + timeoutJobs.remove(subId)?.cancel() + relaysPerSubId[subId] = profileFilter.keys.toMutableSet() + client.subscribe(subId, profileFilter) + timeoutJobs[subId] = scope.launch { + delay(EOSE_TIMEOUT_MS) + if (relaysPerSubId.containsKey(subId)) { + unsubscribe(subId) + } + } + } + + /** + * Called when a relay list subscription completes (all relays sent EOSE or the timeout + * fired). Starts the metadata fetch for the account using the just-saved relay list. + * No-op for metadata subscription ids. + */ + private fun onRelayListSubFinished(subId: String) { + val hexKey = relayListSubIds.entries.firstOrNull { it.value == subId }?.key ?: return + relayListSubIds.remove(hexKey) + accounts[hexKey]?.let { subscribeToProfile(it) } + } + + /** + * Saves the newest kind-10002 write relay list locally so profile fetches can also + * query the user's own relays. + */ + private fun saveUserRelays(event: AdvertisedRelayListEvent) { + val account = accounts[event.pubKey] ?: return + val relays = event.writeRelaysNorm() ?: event.relaysNorm() + if (relays.isEmpty()) return + if (event.createdAt <= LocalPreferences.getUserRelaysCreatedAt(appContext, account.npub)) return + LocalPreferences.setUserRelays(appContext, account.npub, relays, event.createdAt) + } + + private fun unsubscribe(subId: String) { + timeoutJobs.remove(subId)?.cancel() + Amber.instance.intentionalDisconnectTime = System.currentTimeMillis() + client.unsubscribe(subId) + relaysPerSubId.remove(subId) + } + /** * Re-runs the fetch for every currently tracked account. Call when the relay list changes. */ @@ -175,11 +230,8 @@ class ProfileSubscription( */ fun closeSub(account: Account) { accounts.remove(account.hexKey) - val subId = subIds.remove(account.hexKey) ?: return - timeoutJobs.remove(subId)?.cancel() - Amber.instance.intentionalDisconnectTime = System.currentTimeMillis() - client.unsubscribe(subId) - relaysPerSubId.remove(subId) + relayListSubIds.remove(account.hexKey)?.let { unsubscribe(it) } + subIds.remove(account.hexKey)?.let { unsubscribe(it) } } /** @@ -187,26 +239,40 @@ class ProfileSubscription( */ fun closeSub() { Amber.instance.intentionalDisconnectTime = System.currentTimeMillis() - subIds.values.forEach { + (subIds.values + relayListSubIds.values).forEach { timeoutJobs.remove(it)?.cancel() client.unsubscribe(it) } relaysPerSubId.clear() subIds.clear() + relayListSubIds.clear() accounts.clear() } - private fun createProfileFilter(account: Account): Map> { - val relays = LocalPreferences.loadSettingsFromEncryptedStorage().defaultProfileRelays - val accounts = listOf(account.hexKey) - return relays.associateWith { - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - authors = accounts, - limit = accounts.size, - ), - ) - } + /** Default profile relays from the settings plus the user's own saved relay list. */ + private fun profileRelays(account: Account): Set { + val defaultRelays = LocalPreferences.loadSettingsFromEncryptedStorage().defaultProfileRelays + val userRelays = LocalPreferences.getUserRelays(appContext, account.npub) + return (defaultRelays + userRelays).toSet() + } + + private fun createRelayListFilter(account: Account): Map> = profileRelays(account).associateWith { + listOf( + Filter( + kinds = listOf(AdvertisedRelayListEvent.KIND), + authors = listOf(account.hexKey), + limit = 1, + ), + ) + } + + private fun createProfileFilter(account: Account): Map> = profileRelays(account).associateWith { + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(account.hexKey), + limit = 1, + ), + ) } }