Merge 369b047fc45aadccc8b3384ee52ef4986a52c12a into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
heyaim 2026-09-14 12:21:30 -05:00 committed by GitHub
commit 447983434b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 196 additions and 13 deletions

View File

@ -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

View File

@ -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<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 +270,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 +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)

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`() {
// 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"))
}
}

View File

@ -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: