diff --git a/.github/workflows/fetch-georelays.yml b/.github/workflows/fetch-georelays.yml index 8f91c53d..f2300bd0 100644 --- a/.github/workflows/fetch-georelays.yml +++ b/.github/workflows/fetch-georelays.yml @@ -19,9 +19,11 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Fetch GeoRelays + # Must match RelayDirectory.ASSET_FILE_URL, the file the app fetches at + # runtime; the bundled asset is a snapshot of the same file. run: | - wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv - mv nostr_relays.csv ./app/src/main/assets/nostr_relays.csv + wget https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv + mv online_relays_gps.csv ./app/src/main/assets/nostr_relays.csv - name: Check for changes id: git-check 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 980b95ef..fd7d5d2a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -26,7 +26,15 @@ import okhttp3.Request object RelayDirectory { private const val TAG = "RelayDirectory" - private const val ASSET_FILE_URL = "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv" + + // The same file iOS reads (GeoRelayDirectory.swift). Both platforms take the 5 + // nearest relays from their directory and use them without the defaults, so a + // geohash message crosses platforms only if the two selections share a relay. + // Reading different files made the selections diverge. Selecting from the same + // file, with rows keyed and ordered the same way, keeps them aligned. + internal const val ASSET_FILE_URL = "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv" + // Download cache of ASSET_FILE_URL above, and the bundled list the weekly job + // refreshes from it; the file names predate the source's move to online_relays_gps.csv. private const val ASSET_FILE = "nostr_relays.csv" private const val DOWNLOADED_FILE = "nostr_relays_latest.csv" private const val PREFS_NAME = "relay_directory_prefs" @@ -97,14 +105,21 @@ 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 closestRelays(snapshot, lat, lon, nRelays) } + // Distance ties are the directory's common case, not an edge: rows are geocoded + // to city centroids, so whole groups of relays sit at one exact coordinate. iOS + // breaks ties by host so every device with the same directory picks the same set + // (GeoRelayDirectory.closestRelays). Urls here are canonical hosts behind a fixed + // prefix, so ordering by url reproduces iOS's order. + internal fun closestRelays(entries: List, lat: Double, lon: Double, nRelays: Int): List = + entries + .map { it to haversineMeters(lat, lon, it.latitude, it.longitude) } + .sortedWith(compareBy({ it.second }, { it.first.url })) + .take(nRelays.coerceAtLeast(0)) + .map { it.first.url } + private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val R = 6371000.0 // meters val dLat = Math.toRadians(lat2 - lat1) @@ -255,8 +270,12 @@ object RelayDirectory { Log.i(TAG, "📦 Loaded ${list.size} relay entries from assets/$ASSET_FILE, sha256=$hash") } - private fun parseCsv(input: InputStream): List { + internal fun parseCsv(input: InputStream): List { val result = mutableListOf() + // The directory lists some relays twice, once bare and once with an explicit + // :443, which is the same server over wss. Without this check both copies can + // land in a nearest-N selection, and one of its slots connects nowhere new. + val seenEndpoints = HashSet() BufferedReader(InputStreamReader(input)).use { reader -> var line: String? while (true) { @@ -267,16 +286,35 @@ object RelayDirectory { if (trimmed.lowercase().startsWith("relay url")) continue val parts = trimmed.split(",") if (parts.size < 3) continue - val url = normalizeRelayUrl(parts[0].trim()) + val raw = normalizeRelayUrl(parts[0].trim()) val lat = parts[1].trim().toDoubleOrNull() val lon = parts[2].trim().toDoubleOrNull() - if (url.isEmpty() || lat == null || lon == null) continue - result.add(RelayInfo(url = url, latitude = lat, longitude = lon)) + if (raw.isEmpty() || lat == null || lon == null) continue + val canonical = canonicalHost(raw) + if (canonical.isEmpty() || !seenEndpoints.add(canonical)) continue + result.add(RelayInfo(url = "wss://$canonical", latitude = lat, longitude = lon)) } } return result } + /** + * The host string iOS builds for the same row (GeoRelayDirectory's + * validatedDirectoryAddress): host lowercased, an explicit port kept unless it is + * 443, the wss default. A relay on :8443 stays distinct from one on :443. Dedup + * and tie ordering both key on this, which is what keeps the two platforms' + * selections aligned row for row. + */ + internal fun canonicalHost(url: String): String { + val hostPort = url.substringAfter("://").substringBefore("/") + val idx = hostPort.lastIndexOf(':') + val hasPort = idx > 0 && idx < hostPort.length - 1 && + hostPort.substring(idx + 1).all { it.isDigit() } + val host = (if (hasPort) hostPort.substring(0, idx) else hostPort).lowercase() + val port = if (hasPort) hostPort.substring(idx + 1).toInt() else 443 + return if (port == 443) host else "$host:$port" + } + private fun fileSha256Hex(file: File): String = try { FileInputStream(file).use { input -> streamSha256Hex(input) diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt new file mode 100644 index 00000000..d5541d91 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt @@ -0,0 +1,134 @@ +package com.bitchat.android.nostr + +import java.io.ByteArrayInputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the three properties cross-platform geohash delivery depends on. Both platforms + * take the 5 relays nearest to a geohash and use them without the default relays, so a + * geohash message crosses platforms only if the two selections intersect. + * + * 1. Android reads the SAME directory file iOS reads. + * 2. Rows are deduplicated and emitted by the same key iOS builds: host lowercased, + * an explicit port kept unless it is 443. The directory lists many relays twice, + * once bare and once as host:443, and both forms are one server over wss. + * 3. Distance ties order by that key, the way iOS orders them. Rows are geocoded to + * city centroids, so whole tie groups sit at one coordinate and tie order decides + * most selections. + */ +class RelayDirectoryTest { + + private fun parse(csv: String) = + RelayDirectory.parseCsv(ByteArrayInputStream(csv.toByteArray())) + + private val header = "Relay URL,Latitude,Longitude\n" + + @Test + fun `fetch url is the file ios reads`() { + assertEquals( + "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv", + RelayDirectory.ASSET_FILE_URL + ) + } + + @Test + fun `canonical host matches the key ios builds`() { + assertEquals("relay.example.com", RelayDirectory.canonicalHost("wss://relay.example.com")) + assertEquals("relay.example.com", RelayDirectory.canonicalHost("wss://relay.example.com:443")) + assertEquals("relay.example.com:8443", RelayDirectory.canonicalHost("wss://relay.example.com:8443")) + assertEquals("relay.example.com", RelayDirectory.canonicalHost("wss://Relay.Example.Com/")) + } + + @Test + fun `port 443 variant of a host is the same server and is not listed twice`() { + val entries = parse( + header + + "relay.example.com,10.0,20.0\n" + + "relay.example.com:443,10.0,20.0\n" + ) + assertEquals(1, entries.size) + assertEquals("wss://relay.example.com", entries[0].url) + } + + @Test + fun `a nonstandard port is a different server and stays`() { + // The live directory lists some relays bare and on a nonstandard port. + // iOS keeps both too; it drops only an explicit 443. + val entries = parse( + header + + "port-variant.relay.example,1.0,1.0\n" + + "port-variant.relay.example:8443,1.0,1.0\n" + ) + assertEquals(2, entries.size) + assertEquals("wss://port-variant.relay.example", entries[0].url) + assertEquals("wss://port-variant.relay.example:8443", entries[1].url) + } + + @Test + fun `first row wins when an endpoint is listed twice`() { + val entries = parse( + header + + "relay.example.com,10.0,20.0\n" + + "relay.example.com:443,50.0,60.0\n" + ) + assertEquals(1, entries.size) + assertEquals(10.0, entries[0].latitude, 0.0) + } + + @Test + fun `host case does not create a second endpoint`() { + val entries = parse( + header + + "Relay.Example.Com:443,10.0,20.0\n" + + "relay.example.com,10.0,20.0\n" + ) + assertEquals(1, entries.size) + assertEquals("wss://relay.example.com", entries[0].url) + } + + @Test + fun `distance ties order by host the way ios orders them`() { + // The directory's dominant shape: a whole tie group at one shared coordinate + // (the live file has 137 rows at a single point). File order is + // deliberately not alphabetical; the selection must not depend on it. + val csv = header + + "delta.example.com,12.34,56.78\n" + + "foxtrot.example.com,12.34,56.78\n" + + "alpha.example.com:443,12.34,56.78\n" + + "echo.example.com,12.34,56.78\n" + + "bravo.example.com,12.34,56.78\n" + + "charlie.example.com,12.34,56.78\n" + val five = RelayDirectory.closestRelays(parse(csv), 12.34, 56.78, 5) + assertEquals( + listOf( + "wss://alpha.example.com", + "wss://bravo.example.com", + "wss://charlie.example.com", + "wss://delta.example.com", + "wss://echo.example.com" + ), + five + ) + } + + @Test + fun `five nearest means five distinct servers`() { + // A shape the live directory produces: the nearest relay listed twice + // (bare and :443), which used to occupy two of the five selection slots and + // push out the fifth distinct server. + val csv = header + + "nearest.example.com,10.10,20.20\n" + + "nearest.example.com:443,10.10,20.20\n" + + "second.example.com,10.20,20.20\n" + + "third.example.com,10.30,20.20\n" + + "fourth.example.com,10.40,20.20\n" + + "fifth.example.com,10.50,20.20\n" + + "faraway.example.com,80.0,120.0\n" + val five = RelayDirectory.closestRelays(parse(csv), 10.10, 20.20, 5) + assertEquals(5, five.size) + assertEquals("every selected relay is a distinct server", 5, five.toSet().size) + assertTrue("the fifth distinct server makes the cut", five.contains("wss://fifth.example.com")) + } +} diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 05d5e1ad..f37809f0 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -36,6 +36,15 @@ randomization used by senders. Android caps outbound seal and gift-wrap randomization at 22h, leaving 2 hours of slack inside iOS's 24-hour subscription window, while retaining its 48-hour receive lookback. +Geohash relay selection is part of the cross-client contract. Both platforms +read `relays/online_relays_gps.csv` from the bitchat repo, key each row by its +host string (lowercased, an explicit port kept unless it is 443, the wss +default), deduplicate by that key, and order candidates by distance with ties +broken by the same key. Clients that select differently can end up on disjoint +relay sets for the same geohash and silently fail to exchange messages. +`RelayDirectoryTest` covers the Android side; iOS implements the same rules in +`GeoRelayDirectory`. + ## Rewrite acceptance gate From a configured Android development environment, run: