From 888b3df13a67c470db48a0d118a1870f2615c45d Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:01:10 -0500 Subject: [PATCH 1/4] Select geohash relays the way iOS selects them Both platforms use only the five relays nearest a geohash, and a message crosses platforms only if the two selections share a relay. Android and iOS read different relay files; measured Aug 11 over 6,013 points against iOS's own selection code, the two selections shared 2.3 of 5 relays on average, and about one in nine points shared none. Fetch the file iOS reads. Key each row by the host string iOS builds, which collapses the 115 hosts listed twice. Order distance ties by that key. With all three, both platforms select the same five, in order, at all 6,013 points. The bundled asset is left to the weekly job. --- .../bitchat/android/nostr/RelayDirectory.kt | 58 ++++++-- .../android/nostr/RelayDirectoryTest.kt | 134 ++++++++++++++++++ 2 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.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 980b95ef..cf85bb03 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,13 @@ 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" 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 +103,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 +268,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 +284,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..c94b578b --- /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`() { + // Real case from the live directory: bendernostur.duckdns.org is listed bare + // and on 8443. iOS keeps both too; it drops only an explicit 443. + val entries = parse( + header + + "bendernostur.duckdns.org,1.0,1.0\n" + + "bendernostur.duckdns.org:8443,1.0,1.0\n" + ) + assertEquals(2, entries.size) + assertEquals("wss://bendernostur.duckdns.org", entries[0].url) + assertEquals("wss://bendernostur.duckdns.org: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 city centroid + // (the live file has 137 rows at a single coordinate). File order is + // deliberately not alphabetical; the selection must not depend on it. + val csv = header + + "delta.example.com,43.6532,-79.3832\n" + + "foxtrot.example.com,43.6532,-79.3832\n" + + "alpha.example.com:443,43.6532,-79.3832\n" + + "echo.example.com,43.6532,-79.3832\n" + + "bravo.example.com,43.6532,-79.3832\n" + + "charlie.example.com,43.6532,-79.3832\n" + val five = RelayDirectory.closestRelays(parse(csv), 43.6532, -79.3832, 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`() { + // The shape measured for London on Aug 11 2026: 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,51.50,-0.12\n" + + "nearest.example.com:443,51.50,-0.12\n" + + "second.example.com,51.60,-0.10\n" + + "third.example.com,51.70,-0.10\n" + + "fourth.example.com,51.80,-0.10\n" + + "fifth.example.com,51.90,-0.10\n" + + "faraway.example.com,40.0,30.0\n" + val five = RelayDirectory.closestRelays(parse(csv), 51.5074, -0.1278, 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")) + } +} From 43ce034bcd412ad5cd5dc6d3e2be0f22b7dee7dc Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:01:10 -0500 Subject: [PATCH 2/4] Use synthetic fixtures and record the selection contract Review follow-up from the automated pass. The selection tests carried a real relay hostname and real city coordinates; AGENTS.md requires synthetic, non-identifying fixtures. Both are replaced with clearly synthetic values that keep the same shapes: the port-variant pair, the one-coordinate tie group, and the duplicated nearest relay that used to displace the fifth server. The selection rules are also now recorded in docs/client-rewrite-contracts.md, since agreeing clients are the point of the change: same source file, same host key, same tie order. Behavior is unchanged; RelayDirectoryTest covers it. --- .../android/nostr/RelayDirectoryTest.kt | 48 +++++++++---------- docs/client-rewrite-contracts.md | 9 ++++ 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt index c94b578b..d5541d91 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt @@ -54,16 +54,16 @@ class RelayDirectoryTest { @Test fun `a nonstandard port is a different server and stays`() { - // Real case from the live directory: bendernostur.duckdns.org is listed bare - // and on 8443. iOS keeps both too; it drops only an explicit 443. + // 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 + - "bendernostur.duckdns.org,1.0,1.0\n" + - "bendernostur.duckdns.org:8443,1.0,1.0\n" + "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://bendernostur.duckdns.org", entries[0].url) - assertEquals("wss://bendernostur.duckdns.org:8443", entries[1].url) + assertEquals("wss://port-variant.relay.example", entries[0].url) + assertEquals("wss://port-variant.relay.example:8443", entries[1].url) } @Test @@ -90,17 +90,17 @@ class RelayDirectoryTest { @Test fun `distance ties order by host the way ios orders them`() { - // The directory's dominant shape: a whole tie group at one city centroid - // (the live file has 137 rows at a single coordinate). File order is + // 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,43.6532,-79.3832\n" + - "foxtrot.example.com,43.6532,-79.3832\n" + - "alpha.example.com:443,43.6532,-79.3832\n" + - "echo.example.com,43.6532,-79.3832\n" + - "bravo.example.com,43.6532,-79.3832\n" + - "charlie.example.com,43.6532,-79.3832\n" - val five = RelayDirectory.closestRelays(parse(csv), 43.6532, -79.3832, 5) + "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", @@ -115,18 +115,18 @@ class RelayDirectoryTest { @Test fun `five nearest means five distinct servers`() { - // The shape measured for London on Aug 11 2026: the nearest relay listed twice + // 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,51.50,-0.12\n" + - "nearest.example.com:443,51.50,-0.12\n" + - "second.example.com,51.60,-0.10\n" + - "third.example.com,51.70,-0.10\n" + - "fourth.example.com,51.80,-0.10\n" + - "fifth.example.com,51.90,-0.10\n" + - "faraway.example.com,40.0,30.0\n" - val five = RelayDirectory.closestRelays(parse(csv), 51.5074, -0.1278, 5) + "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: From 54a0b4dd4546e6a1e6ff0bcd1bd6280994ad0b40 Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:01:10 -0500 Subject: [PATCH 3/4] Record the source of the bundled and cached relay files Review follow-up on the naming: the bundled asset and the download cache kept their old file names when the fetch moved to online_relays_gps.csv. A comment now records where both come from; the names then do not send anyone looking for a georelays fetch that is gone. --- app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt | 2 ++ 1 file changed, 2 insertions(+) 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 cf85bb03..fd7d5d2a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -33,6 +33,8 @@ object RelayDirectory { // 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" From 369b047fc45aadccc8b3384ee52ef4986a52c12a Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:01:10 -0500 Subject: [PATCH 4/4] Fetch the weekly relay update from the file the app reads fetch-georelays.yml downloads the relay list from the georelays repo every week and pushes it straight to main. This branch moves the app's fetch to online_relays_gps.csv, and a job still reading the old source would keep the bundled asset on a different list from the one the app downloads. Pointed at online_relays_gps.csv, the job keeps the bundled asset on the app's source. When the file has not changed, the job downloads the same file and pushes nothing; when it changes, the asset follows. The asset itself is not changed in this branch. --- .github/workflows/fetch-georelays.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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