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.
This commit is contained in:
heyaim 2026-09-07 19:01:10 -05:00
parent 945c9e025b
commit 888b3df13a
2 changed files with 181 additions and 11 deletions

View File

@ -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<RelayInfo>, lat: Double, lon: Double, nRelays: Int): List<String> =
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<RelayInfo> {
internal fun parseCsv(input: InputStream): List<RelayInfo> {
val result = mutableListOf<RelayInfo>()
// 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<String>()
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)

View File

@ -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"))
}
}