From 842b8f81f1d1687ae629c97cdc916696cf1be885 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 8 Nov 2026 12:18:35 +0530 Subject: [PATCH] Collapse duplicate geohash relays and break distance ties by host. host and host:443 were taking two of the five closest slots, and equal distances followed CSV order instead of the iOS host tie-break. --- .../bitchat/android/nostr/RelayDirectory.kt | 51 ++++++++++++++++--- .../nostr/RelayDirectorySelectionTest.kt | 37 ++++++++++++++ 2 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectorySelectionTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt index 980b95efd..76c101c85 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -97,12 +97,31 @@ object RelayDirectory { } val (lat, lon) = center - return snapshot - .asSequence() - .sortedBy { haversineMeters(lat, lon, it.latitude, it.longitude) } - .take(nRelays.coerceAtLeast(0)) - .map { it.url } - .toList() + return selectClosest(snapshot, lat, lon, nRelays) + } + + /** + * Same distance order as iOS, with the host as the tie-break, after + * `host` and `host:443` have been collapsed to one URL. + */ + internal fun selectClosest( + entries: List, + lat: Double, + lon: Double, + count: Int + ): List { + if (entries.isEmpty() || count <= 0) return emptyList() + val seen = LinkedHashSet() + entries + .map { it.copy(url = canonicalRelayUrl(it.url)) } + .sortedWith( + compareBy { haversineMeters(lat, lon, it.latitude, it.longitude) } + .thenBy { relaySortKey(it.url) } + ) + .forEach { entry -> + if (entry.url.isNotEmpty()) seen.add(entry.url) + } + return seen.take(count).toList() } private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { @@ -114,12 +133,28 @@ object RelayDirectory { return R * c } - private fun normalizeRelayUrl(raw: String): String { + internal fun canonicalRelayUrl(raw: String): String { val trimmed = raw.trim() if (trimmed.isEmpty()) return trimmed - return if ("://" in trimmed) trimmed else "wss://$trimmed" + val withScheme = if ("://" in trimmed) trimmed else "wss://$trimmed" + val schemeEnd = withScheme.indexOf("://") + if (schemeEnd < 0) return withScheme + val scheme = withScheme.substring(0, schemeEnd).lowercase() + val rest = withScheme.substring(schemeEnd + 3) + val authority = rest.substringBefore('/').substringBefore('?').substringBefore('#') + val tail = rest.removePrefix(authority) + val host = authority.substringBefore(':').lowercase() + if (host.isEmpty()) return withScheme + val port = authority.substringAfter(':', "") + val authorityOut = if (port.isEmpty() || port == "443") host else "$host:$port" + return "$scheme://$authorityOut$tail" } + private fun relaySortKey(url: String): String = + url.substringAfter("://", url).substringBefore('/').substringBefore(':') + + private fun normalizeRelayUrl(raw: String): String = canonicalRelayUrl(raw) + // ===== Implementation details ===== private fun getPrefs(application: Application): SharedPreferences = diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectorySelectionTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectorySelectionTest.kt new file mode 100644 index 000000000..249d9eb94 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectorySelectionTest.kt @@ -0,0 +1,37 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RelayDirectorySelectionTest { + + @Test + fun `canonical url drops the default port and lowercases the host`() { + assertEquals( + "wss://relay.example.com", + RelayDirectory.canonicalRelayUrl("relay.example.com:443") + ) + assertEquals( + "wss://relay.example.com", + RelayDirectory.canonicalRelayUrl("WSS://Relay.Example.com:443") + ) + assertEquals( + "wss://relay.example.com:8443", + RelayDirectory.canonicalRelayUrl("relay.example.com:8443") + ) + } + + @Test + fun `duplicate host and port do not take two of the five slots`() { + val entries = listOf( + RelayDirectory.RelayInfo("wss://b.example", 0.0, 0.0), + RelayDirectory.RelayInfo("b.example:443", 0.0, 0.0), + RelayDirectory.RelayInfo("a.example", 0.0, 0.0), + RelayDirectory.RelayInfo("c.example", 1.0, 1.0) + ) + + val picked = RelayDirectory.selectClosest(entries, lat = 0.0, lon = 0.0, count = 2) + + assertEquals(listOf("wss://a.example", "wss://b.example"), picked) + } +}