mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge 369b047fc45aadccc8b3384ee52ef4986a52c12a into c127eb83ab94c069c32d37530d2faecd381cd2a8
This commit is contained in:
commit
447983434b
6
.github/workflows/fetch-georelays.yml
vendored
6
.github/workflows/fetch-georelays.yml
vendored
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user