diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt index fb40475a..3c95a9c7 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt @@ -79,6 +79,11 @@ internal class NostrPendingEventQueue( } } + fun removeRelay(relayUrl: String) = synchronized(lock) { + entries.forEach { it.pendingRelayUrls.remove(relayUrl) } + entries.removeAll { it.pendingRelayUrls.isEmpty() } + } + fun removeLiveLocationEvents() { synchronized(lock) { entries.removeAll { it.liveLocationToken != null } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index ca619345..d8956640 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -83,6 +83,7 @@ class NostrRelayManager private constructor() { private val _customRelays = MutableStateFlow>(emptyList()) val customRelays: StateFlow> = _customRelays.asStateFlow() private var customRelaysLoaded = false + private val removedCustomRelayUrls = java.util.concurrent.ConcurrentHashMap.newKeySet() @Synchronized private fun loadCustomRelays(context: android.content.Context) { @@ -101,6 +102,7 @@ class NostrRelayManager private constructor() { if (url in defaultRelays()) return true if (_customRelays.value.size >= 20) return false val previousDefaults = defaultRelays().toSet() + removedCustomRelayUrls.remove(url) _customRelays.value = _customRelays.value + url persistCustomRelays() nonLiveRelayUrls.add(url) @@ -116,6 +118,8 @@ class NostrRelayManager private constructor() { @Synchronized fun removeCustomRelay(url: String) { if (url !in _customRelays.value) return + removedCustomRelayUrls.add(url) + messageQueue.removeRelay(url) _customRelays.value = _customRelays.value - url persistCustomRelays() activeSubscriptions.replaceAll { _, subscription -> @@ -930,7 +934,7 @@ class NostrRelayManager private constructor() { urlString: String, liveLocationToken: Long? = null ) { - if (!desiredConnected.get()) return + if (!desiredConnected.get() || urlString in removedCustomRelayUrls) return val connectionToken = liveLocationToken ?.takeIf { urlString !in nonLiveRelayUrls } if (!isNetworkActionAllowed(connectionToken)) return @@ -945,6 +949,7 @@ class NostrRelayManager private constructor() { .build() val started = runNetworkAction(connectionToken) { + if (urlString in removedCustomRelayUrls) return@runNetworkAction val webSocket = httpClient.newWebSocket( request, RelayWebSocketListener(urlString, connectionToken) @@ -952,7 +957,7 @@ class NostrRelayManager private constructor() { val existing = connections.putIfAbsent(urlString, webSocket) when { existing != null -> webSocket.close(1000, "Duplicate connection") - !desiredConnected.get() -> { + !desiredConnected.get() || urlString in removedCustomRelayUrls -> { connections.remove(urlString, webSocket) webSocket.close(1000, "Connection no longer desired") } @@ -973,14 +978,14 @@ class NostrRelayManager private constructor() { liveLocationToken: Long? = null, publicationAllowed: () -> Boolean = { true } ): Boolean { - if (!publicationAllowed() || !isNetworkActionAllowed(liveLocationToken)) return false + if (!publicationAllowed() || relayUrl in removedCustomRelayUrls || !isNetworkActionAllowed(liveLocationToken)) return false return try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) var success = false runNetworkAction(liveLocationToken) { - if (publicationAllowed()) success = webSocket.send(message) + if (publicationAllowed() && relayUrl !in removedCustomRelayUrls) success = webSocket.send(message) } if (success) { // Update relay stats @@ -1238,7 +1243,7 @@ class NostrRelayManager private constructor() { ) : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { - if (!desiredConnected.get() || + if (!desiredConnected.get() || relayUrl in removedCustomRelayUrls || connections[relayUrl] !== webSocket || !isNetworkActionAllowed(liveLocationToken) ) { diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt index b8e5fa9b..37747a38 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt @@ -82,6 +82,17 @@ class NostrPendingEventQueueTest { assertEquals(0, queue.size()) } + @Test + fun `removing a relay drops its pending work without losing other deliveries`() { + val queue = NostrPendingEventQueue(4) + queue.enqueue(event("shared"), listOf("removed", "retained"), null) + queue.enqueue(event("removed-only"), listOf("removed"), null) + queue.removeRelay("removed") + assertEquals(0, queue.pendingForRelay("removed").size) + assertEquals(listOf("shared"), queue.pendingForRelay("retained").map { it.event.content }) + assertEquals(1, queue.size()) + } + private fun event(content: String): NostrEvent { val privateKey = "0".repeat(63) + "1" return NostrEvent( diff --git a/docs/ios-feature-parity-review.md b/docs/ios-feature-parity-review.md index 29d1cf9d..a27d28d5 100644 --- a/docs/ios-feature-parity-review.md +++ b/docs/ios-feature-parity-review.md @@ -116,8 +116,8 @@ Physical Mesh Lab and Android-to-iOS interoperability are required separately: - One-time-prekey races and consumption persistence, plus iOS vectors on both real clients. Static screenshots cannot establish any of these properties. -The updated panic-help sentence falls back to English where previous translations -still promised immediate erasure; those stale claims are removed. Full locale coverage, accessibility review and the physical matrix remain +The updated panic-help sentence uses the English resource fallback until translated. +Full locale coverage, accessibility review and the physical matrix remain release work. Manual-channel Nostr board publication still obeys the existing location-note privacy gate; this integration does not weaken it to force a successful publish. No claim is made that this large integration is ready to