mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge 91b529eb7055d3e52b72a5f953abb1c9acd956ab into c127eb83ab94c069c32d37530d2faecd381cd2a8
This commit is contained in:
commit
95e2b55ae1
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
|
||||
|
||||
@ -3,12 +3,10 @@ package com.bitchat.android.nostr
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.InputStreamReader
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.*
|
||||
@ -26,13 +24,33 @@ 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"
|
||||
private const val KEY_LAST_UPDATE_MS = "last_update_ms"
|
||||
private const val KEY_SOURCE_URL = "source_url"
|
||||
private val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1)
|
||||
|
||||
// GeoRelayDirectoryValidationPolicy.live, ported verbatim. The directory is an
|
||||
// unsigned third-party file (docs/security-review-jul-27.md M9); iOS bounds what
|
||||
// it will accept from it and rejects the rest, and both platforms must bound it
|
||||
// the same way or a file one side accepts and the other rejects splits their
|
||||
// relay selections at every geohash at once.
|
||||
internal const val MAX_DIRECTORY_BYTES = 512 * 1024
|
||||
internal const val MAX_DIRECTORY_ROWS = 5_000
|
||||
internal const val MAX_DIRECTORY_ENTRIES = 5_000
|
||||
internal const val MIN_REMOTE_ENTRIES = 50
|
||||
internal const val MIN_RETAINED_FRACTION = 0.5
|
||||
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val httpClient: OkHttpClient
|
||||
get() = com.bitchat.android.net.OkHttpProvider.httpClient()
|
||||
@ -55,6 +73,7 @@ object RelayDirectory {
|
||||
if (initialized) return
|
||||
try {
|
||||
val downloaded = getDownloadedFile(application)
|
||||
invalidateCacheIfSourceChanged(getPrefs(application), downloaded)
|
||||
val loadedFromDownloaded = if (downloaded.exists() && downloaded.canRead()) {
|
||||
loadFromFile(downloaded, sourceLabel = "downloaded")
|
||||
} else {
|
||||
@ -97,14 +116,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)
|
||||
@ -114,17 +140,26 @@ object RelayDirectory {
|
||||
return R * c
|
||||
}
|
||||
|
||||
private fun normalizeRelayUrl(raw: String): String {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return trimmed
|
||||
return if ("://" in trimmed) trimmed else "wss://$trimmed"
|
||||
}
|
||||
|
||||
// ===== Implementation details =====
|
||||
|
||||
private fun getPrefs(application: Application): SharedPreferences =
|
||||
application.getSharedPreferences(PREFS_NAME, Application.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* An install upgraded across the source move still holds a cache fetched from
|
||||
* the old URL. Drop it and clear the update stamp, and the staleness check
|
||||
* refetches, rather than keep selecting from a file the current source no
|
||||
* longer matches. Returns whether a cache was dropped.
|
||||
*/
|
||||
internal fun invalidateCacheIfSourceChanged(prefs: SharedPreferences, downloaded: File): Boolean {
|
||||
if (!downloaded.exists()) return false
|
||||
if (prefs.getString(KEY_SOURCE_URL, null) == ASSET_FILE_URL) return false
|
||||
downloaded.delete()
|
||||
prefs.edit().remove(KEY_LAST_UPDATE_MS).apply()
|
||||
Log.i(TAG, "Dropped cached relay list fetched from a previous source URL")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getDownloadedFile(application: Application): File =
|
||||
File(application.filesDir, DOWNLOADED_FILE)
|
||||
|
||||
@ -159,9 +194,17 @@ object RelayDirectory {
|
||||
return
|
||||
}
|
||||
|
||||
val parsed = parseCsv(FileInputStream(tmpFile))
|
||||
if (parsed.isEmpty()) {
|
||||
Log.w(TAG, "Downloaded relay CSV parsed to 0 entries; ignoring")
|
||||
if (tmpFile.length() > MAX_DIRECTORY_BYTES) {
|
||||
Log.w(TAG, "Downloaded relay CSV exceeds $MAX_DIRECTORY_BYTES bytes; keeping current list")
|
||||
tmpFile.delete()
|
||||
return
|
||||
}
|
||||
// The current directory is the baseline: a rejected download keeps it,
|
||||
// in memory and on disk, the way iOS keeps its previous copy.
|
||||
val baseline = synchronized(relaysLock) { relays.toSet() }
|
||||
val parsed = validatedEntries(tmpFile.readBytes(), MIN_REMOTE_ENTRIES, baseline)
|
||||
if (parsed == null) {
|
||||
Log.w(TAG, "Downloaded relay CSV failed validation; keeping current list")
|
||||
tmpFile.delete()
|
||||
return
|
||||
}
|
||||
@ -182,7 +225,10 @@ object RelayDirectory {
|
||||
relays.addAll(parsed)
|
||||
}
|
||||
|
||||
getPrefs(application).edit().putLong(KEY_LAST_UPDATE_MS, System.currentTimeMillis()).apply()
|
||||
getPrefs(application).edit()
|
||||
.putLong(KEY_LAST_UPDATE_MS, System.currentTimeMillis())
|
||||
.putString(KEY_SOURCE_URL, ASSET_FILE_URL)
|
||||
.apply()
|
||||
|
||||
Log.i(TAG, "✅ Using downloaded relay list (${dest.absolutePath}), entries=$entries, sha256=$hash, updatedAtMs=${getPrefs(application).getLong(KEY_LAST_UPDATE_MS, 0L)}")
|
||||
} catch (e: Exception) {
|
||||
@ -199,9 +245,24 @@ object RelayDirectory {
|
||||
return false
|
||||
}
|
||||
val body = resp.body ?: return false
|
||||
if (body.contentLength() > MAX_DIRECTORY_BYTES) {
|
||||
Log.w(TAG, "Relay CSV content length exceeds $MAX_DIRECTORY_BYTES bytes; aborting")
|
||||
return false
|
||||
}
|
||||
FileOutputStream(dest).use { out ->
|
||||
body.byteStream().use { input ->
|
||||
input.copyTo(out)
|
||||
val buf = ByteArray(8192)
|
||||
var total = 0L
|
||||
while (true) {
|
||||
val read = input.read(buf)
|
||||
if (read <= 0) break
|
||||
total += read
|
||||
if (total > MAX_DIRECTORY_BYTES.toLong()) {
|
||||
Log.w(TAG, "Relay CSV download exceeded $MAX_DIRECTORY_BYTES bytes; aborting")
|
||||
return false
|
||||
}
|
||||
out.write(buf, 0, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
@ -214,9 +275,9 @@ object RelayDirectory {
|
||||
|
||||
private fun loadFromFile(file: File, sourceLabel: String): Boolean {
|
||||
return try {
|
||||
val list = parseCsv(FileInputStream(file))
|
||||
if (list.isEmpty()) {
|
||||
Log.w(TAG, "${sourceLabel} relay CSV has 0 entries; ignoring")
|
||||
val list = validatedEntries(file.readBytes(), minimumEntries = 1)
|
||||
if (list == null) {
|
||||
Log.w(TAG, "${sourceLabel} relay CSV failed validation; ignoring it")
|
||||
false
|
||||
} else {
|
||||
synchronized(relaysLock) {
|
||||
@ -235,7 +296,11 @@ object RelayDirectory {
|
||||
|
||||
private fun loadFromAssets(application: Application) {
|
||||
val list = try {
|
||||
parseCsv(application.assets.open(ASSET_FILE))
|
||||
val bytes = application.assets.open(ASSET_FILE).use { it.readBytes() }
|
||||
validatedEntries(bytes, minimumEntries = 1) ?: run {
|
||||
Log.e(TAG, "Bundled asset $ASSET_FILE failed validation")
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to open asset $ASSET_FILE: ${e.message}")
|
||||
emptyList()
|
||||
@ -255,26 +320,248 @@ object RelayDirectory {
|
||||
Log.i(TAG, "📦 Loaded ${list.size} relay entries from assets/$ASSET_FILE, sha256=$hash")
|
||||
}
|
||||
|
||||
private fun parseCsv(input: InputStream): List<RelayInfo> {
|
||||
val result = mutableListOf<RelayInfo>()
|
||||
BufferedReader(InputStreamReader(input)).use { reader ->
|
||||
var line: String?
|
||||
while (true) {
|
||||
line = reader.readLine()
|
||||
if (line == null) break
|
||||
val trimmed = line!!.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
if (trimmed.lowercase().startsWith("relay url")) continue
|
||||
val parts = trimmed.split(",")
|
||||
if (parts.size < 3) continue
|
||||
val url = 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))
|
||||
/**
|
||||
* GeoRelayDirectory.validatedEntries, ported in full. One malformed or
|
||||
* conflicting row rejects the complete dataset, and the caller keeps whatever
|
||||
* directory it already has: a partial parse would leave this client selecting
|
||||
* from a different row set than iOS, the failure #914 exists to close. Returns
|
||||
* null when the data is rejected.
|
||||
*
|
||||
* baselineEntries carries the current directory when validating a download. A
|
||||
* new file that keeps less than half of the known entries is rejected even when
|
||||
* well formed, matching iOS: a hijacked or truncated upstream cannot swap the
|
||||
* whole relay population in one fetch.
|
||||
*/
|
||||
internal fun validatedEntries(
|
||||
data: ByteArray,
|
||||
minimumEntries: Int,
|
||||
baselineEntries: Set<RelayInfo>? = null
|
||||
): List<RelayInfo>? {
|
||||
if (data.isEmpty() || data.size > MAX_DIRECTORY_BYTES) return null
|
||||
var text = decodeUtf8Strict(data) ?: return null
|
||||
// Foundation's UTF-8 decode strips one leading BOM before iOS's own BOM
|
||||
// check runs, so a single BOM passes on iOS and only a doubled one is
|
||||
// rejected. Mirror that exactly (verified against the real Swift code).
|
||||
if (text.startsWith('\uFEFF')) text = text.substring(1)
|
||||
if (text.startsWith('\uFEFF')) return null
|
||||
|
||||
val lines = text
|
||||
.split('\u000A', '\u000B', '\u000C', '\u000D', '\u0085', '\u2028', '\u2029')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
val header = lines.firstOrNull() ?: return null
|
||||
if (lines.size - 1 > MAX_DIRECTORY_ROWS) return null
|
||||
|
||||
val headerParts = header.split(",").map { it.trim().lowercase() }
|
||||
val supportedHeaders = listOf(
|
||||
listOf("relay url", "latitude", "longitude"),
|
||||
listOf("relay url", "lat", "lon")
|
||||
)
|
||||
if (headerParts !in supportedHeaders) return null
|
||||
|
||||
val entriesByHost = LinkedHashMap<String, RelayInfo>()
|
||||
for (line in lines.drop(1)) {
|
||||
val parts = line.split(",").map { it.trim() }
|
||||
if (parts.size != 3) return null
|
||||
val host = validatedDirectoryAddress(parts[0]) ?: return null
|
||||
val latitude = parseCoordinate(parts[1]) ?: return null
|
||||
if (latitude !in -90.0..90.0) return null
|
||||
val longitude = parseCoordinate(parts[2]) ?: return null
|
||||
if (longitude !in -180.0..180.0) return null
|
||||
|
||||
val entry = RelayInfo(url = "wss://$host", latitude = latitude, longitude = longitude)
|
||||
val existing = entriesByHost[host]
|
||||
// One endpoint cannot truthfully occupy two coordinates. Matching iOS,
|
||||
// row order does not get to choose which location clients trust. The
|
||||
// comparison is IEEE equality, not equals(): Swift's == calls -0.0 and
|
||||
// 0.0 the same coordinate, and the last equal row's value is kept, raw
|
||||
// bits included, the way Swift dictionary assignment keeps it.
|
||||
if (existing != null && !sameEntry(existing, entry)) {
|
||||
return null
|
||||
}
|
||||
entriesByHost[host] = entry
|
||||
if (entriesByHost.size > MAX_DIRECTORY_ENTRIES) return null
|
||||
}
|
||||
|
||||
val parsed = entriesByHost.values.toList()
|
||||
if (parsed.size < minimumEntries) return null
|
||||
|
||||
if (baselineEntries != null) {
|
||||
val required = ceil(baselineEntries.size * MIN_RETAINED_FRACTION).toInt()
|
||||
val overlap = parsed.count { p -> baselineEntries.any { b -> sameEntry(p, b) } }
|
||||
if (overlap < required) return null
|
||||
}
|
||||
|
||||
return parsed.sortedWith(compareBy({ it.url }, { it.latitude }, { it.longitude }))
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoRelayDirectory.validatedDirectoryAddress, ported in full: the host
|
||||
* key both platforms build for a row, or null when the address is one the
|
||||
* directory must not carry (non-ASCII, credentials, paths, queries, local and
|
||||
* internal names, malformed labels, out-of-range ports). An explicit port stays
|
||||
* in the key unless it is 443, the wss default, which keeps a relay on :8443
|
||||
* distinct and collapses the directory's bare and :443 duplicate rows. Dedup
|
||||
* and tie ordering both key on this, which is what keeps the two platforms'
|
||||
* selections aligned row for row.
|
||||
*/
|
||||
internal fun validatedDirectoryAddress(rawValue: String): String? {
|
||||
val value = rawValue.trim()
|
||||
if (value.isEmpty()) return null
|
||||
if (!value.all { it.code in 0x20..0x7E }) return null
|
||||
|
||||
val encoded = if ("://" in value) value else "wss://$value"
|
||||
// URLComponents percent-decodes before iOS's checks run, so re%6Cay.example
|
||||
// is relay.example to iOS; java.net.URI does not decode. Decode the same
|
||||
// way first, and reject invalid escapes the way URLComponents rejects them.
|
||||
val candidate = percentDecodedOrNull(encoded) ?: return null
|
||||
val uri = try { java.net.URI(candidate) } catch (_: Exception) { return null }
|
||||
val scheme = uri.scheme?.lowercase() ?: return null
|
||||
if (scheme != "wss" && scheme != "https") return null
|
||||
if (uri.userInfo != null) return null
|
||||
if (uri.query != null) return null
|
||||
if (uri.fragment != null) return null
|
||||
val path = uri.path ?: ""
|
||||
if (path.isNotEmpty() && path != "/") return null
|
||||
// java.net.URI follows RFC 2396, which requires the final host label to
|
||||
// start with a letter, and returns no host for names like b.08obllot
|
||||
// that iOS's RFC 3986 parser accepts. When URI refuses only for that
|
||||
// reason, a plain hostname[:port] authority is taken as the host and
|
||||
// the screens below judge it; fuzzing found this as the dominant
|
||||
// divergence class (Android rejecting what iOS accepts).
|
||||
val rawHost = uri.host
|
||||
?: plainAuthorityHost(candidate)
|
||||
?: return null
|
||||
|
||||
val host = rawHost.lowercase()
|
||||
if (host.isEmpty() || host.length > 253) return null
|
||||
if (!host.all { it.code <= 0x7F }) return null
|
||||
if (host.endsWith(".")) return null
|
||||
if (host == "localhost" || host.endsWith(".localhost") ||
|
||||
host.endsWith(".local") || host.endsWith(".internal")) return null
|
||||
|
||||
val labels = host.split(".")
|
||||
if (labels.size < 2) return null
|
||||
// URLComponents IDNA-decodes xn-- labels: valid punycode decodes to
|
||||
// non-ASCII and fails iOS's screen, invalid punycode fails its parse, and
|
||||
// both reject the address (verified against the real validator). java.net
|
||||
// URI passes the label through, so the label form itself is refused here.
|
||||
if (labels.any { it.startsWith("xn--") }) return null
|
||||
if (labels.all { label -> label.all { it.isDigit() } }) return null
|
||||
if (!labels.all { label ->
|
||||
label.length in 1..63 && label.first() != '-' && label.last() != '-' &&
|
||||
label.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
|
||||
}
|
||||
) return null
|
||||
|
||||
val port = if (uri.host != null) uri.port else plainAuthorityPort(candidate)
|
||||
if (port != -1) {
|
||||
if (port !in 1..65535) return null
|
||||
if (port != 443) return "$host:$port"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
|
||||
// The authority of the candidate when it is nothing but hostname[:port] in
|
||||
// the host charset. Anything with userinfo, brackets, escapes, or other
|
||||
// structure stays with java.net.URI's verdict.
|
||||
private fun plainAuthority(candidate: String): Pair<String, Int>? {
|
||||
val afterScheme = candidate.substringAfter("://", "")
|
||||
if (afterScheme.isEmpty()) return null
|
||||
val authority = afterScheme.takeWhile { it != '/' && it != '?' && it != '#' }
|
||||
if (authority.length != afterScheme.length) {
|
||||
val rest = afterScheme.substring(authority.length)
|
||||
if (rest != "/") return null
|
||||
}
|
||||
val colon = authority.lastIndexOf(':')
|
||||
val hostPart: String
|
||||
val port: Int
|
||||
if (colon >= 0) {
|
||||
val portPart = authority.substring(colon + 1)
|
||||
// RFC 3986 allows an empty port ("host:"), and Foundation treats it
|
||||
// as no port at all.
|
||||
if (portPart.isNotEmpty() && !portPart.all { it in '0'..'9' }) return null
|
||||
hostPart = authority.substring(0, colon)
|
||||
port = if (portPart.isEmpty()) -1 else portPart.toIntOrNull() ?: return null
|
||||
} else {
|
||||
hostPart = authority
|
||||
port = -1
|
||||
}
|
||||
if (hostPart.isEmpty()) return null
|
||||
if (!hostPart.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '.' || it == '-' }) return null
|
||||
return hostPart to port
|
||||
}
|
||||
|
||||
private fun plainAuthorityHost(candidate: String): String? = plainAuthority(candidate)?.first
|
||||
|
||||
private fun plainAuthorityPort(candidate: String): Int = plainAuthority(candidate)?.second ?: -1
|
||||
|
||||
// IEEE equality on the coordinates: -0.0 equals 0.0 here, as it does in the
|
||||
// Swift Entry's ==, where equals() would call them different.
|
||||
private fun sameEntry(a: RelayInfo, b: RelayInfo): Boolean =
|
||||
a.url == b.url && a.latitude == b.latitude && a.longitude == b.longitude
|
||||
|
||||
// Strict %XX decoding with UTF-8 byte semantics and no '+' handling. Returns
|
||||
// null on an invalid or truncated escape, matching URLComponents.
|
||||
private fun percentDecodedOrNull(value: String): String? {
|
||||
if ('%' !in value) return value
|
||||
val bytes = java.io.ByteArrayOutputStream(value.length)
|
||||
var i = 0
|
||||
while (i < value.length) {
|
||||
val c = value[i]
|
||||
if (c == '%') {
|
||||
if (i + 2 >= value.length) return null
|
||||
val hi = Character.digit(value[i + 1], 16)
|
||||
val lo = Character.digit(value[i + 2], 16)
|
||||
if (hi < 0 || lo < 0) return null
|
||||
val decoded = ((hi shl 4) or lo).toChar()
|
||||
// URLComponents decodes AFTER structural parsing, so a decoded
|
||||
// ':' stays inside the host and fails iOS's label screen; decoding
|
||||
// it here first would instead create a port. Only escapes that
|
||||
// decode to host-legal characters may pass (verified against the
|
||||
// real validator: %6C and %2E accept, %3A and the rest reject).
|
||||
if (!(decoded in 'A'..'Z' || decoded in 'a'..'z' ||
|
||||
decoded in '0'..'9' || decoded == '.' || decoded == '-')) return null
|
||||
bytes.write(decoded.code)
|
||||
i += 3
|
||||
} else {
|
||||
bytes.write(c.code)
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
return decodeUtf8Strict(bytes.toByteArray())
|
||||
}
|
||||
|
||||
// iOS String(data:encoding:.utf8) fails on invalid UTF-8 where Kotlin's
|
||||
// String(bytes) substitutes replacement characters. Decode strictly so both
|
||||
// platforms reject the same bytes.
|
||||
private fun decodeUtf8Strict(data: ByteArray): String? = try {
|
||||
Charsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
|
||||
.decode(java.nio.ByteBuffer.wrap(data))
|
||||
.toString()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// Two Swift-vs-Java parsing differences, both verified against the real
|
||||
// validator: Double.parseDouble takes trailing f/F/d/D suffixes that Swift
|
||||
// rejects (and in hex those characters are digits, not suffixes), and Swift
|
||||
// accepts hex like "0x10" without the binary exponent Java requires.
|
||||
// Infinity and NaN spellings parse on both and fail the finite check.
|
||||
private fun parseCoordinate(raw: String): Double? {
|
||||
if (raw.isEmpty()) return null
|
||||
val body = raw.removePrefix("+").removePrefix("-")
|
||||
val isHex = body.startsWith("0x") || body.startsWith("0X")
|
||||
if (!isHex) {
|
||||
val last = raw.last()
|
||||
if (last == 'f' || last == 'F' || last == 'd' || last == 'D') return null
|
||||
}
|
||||
val candidate = if (isHex && !raw.contains('p') && !raw.contains('P')) raw + "p0" else raw
|
||||
val value = candidate.toDoubleOrNull() ?: return null
|
||||
return if (value.isFinite()) value else null
|
||||
}
|
||||
|
||||
private fun fileSha256Hex(file: File): String = try {
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* A cached directory is only as current as the URL it was fetched from. An install
|
||||
* upgraded across the source move still holds a cache from the old URL; these pin
|
||||
* that it is dropped and refetched instead of selecting from stale data.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
|
||||
class RelayDirectoryCacheInvalidationTest {
|
||||
|
||||
private val application: Application = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private fun prefs() = application.getSharedPreferences("relay_directory_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private fun cacheFile(content: String = "stub"): File =
|
||||
File(application.filesDir, "test_relay_cache.csv").apply { writeText(content) }
|
||||
|
||||
@Test
|
||||
fun `a cache with no recorded source url is dropped`() {
|
||||
val cache = cacheFile()
|
||||
prefs().edit().clear().putLong("last_update_ms", 123L).commit()
|
||||
|
||||
val dropped = RelayDirectory.invalidateCacheIfSourceChanged(prefs(), cache)
|
||||
|
||||
assertTrue(dropped)
|
||||
assertFalse(cache.exists())
|
||||
assertFalse(prefs().contains("last_update_ms"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cache recorded from a different source url is dropped`() {
|
||||
val cache = cacheFile()
|
||||
prefs().edit().clear()
|
||||
.putString("source_url", "https://old.host.example/nostr_relays.csv")
|
||||
.putLong("last_update_ms", 123L)
|
||||
.commit()
|
||||
|
||||
val dropped = RelayDirectory.invalidateCacheIfSourceChanged(prefs(), cache)
|
||||
|
||||
assertTrue(dropped)
|
||||
assertFalse(cache.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cache recorded from the current source url is kept`() {
|
||||
val cache = cacheFile()
|
||||
prefs().edit().clear()
|
||||
.putString("source_url", RelayDirectory.ASSET_FILE_URL)
|
||||
.putLong("last_update_ms", 123L)
|
||||
.commit()
|
||||
|
||||
val dropped = RelayDirectory.invalidateCacheIfSourceChanged(prefs(), cache)
|
||||
|
||||
assertFalse(dropped)
|
||||
assertTrue(cache.exists())
|
||||
assertTrue(prefs().contains("last_update_ms"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing cache changes nothing`() {
|
||||
val cache = File(application.filesDir, "absent.csv")
|
||||
prefs().edit().clear().putLong("last_update_ms", 123L).commit()
|
||||
|
||||
val dropped = RelayDirectory.invalidateCacheIfSourceChanged(prefs(), cache)
|
||||
|
||||
assertFalse(dropped)
|
||||
assertTrue(prefs().contains("last_update_ms"))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
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.
|
||||
*
|
||||
* Parsing goes through the validator ported from GeoRelayDirectory.validatedEntries;
|
||||
* RelayDirectoryValidationTest pins its acceptance rules. These tests parse files the
|
||||
* validator accepts.
|
||||
*/
|
||||
class RelayDirectoryTest {
|
||||
|
||||
private fun parse(csv: String) =
|
||||
requireNotNull(RelayDirectory.validatedEntries(csv.toByteArray(), minimumEntries = 1)) {
|
||||
"fixture unexpectedly rejected"
|
||||
}
|
||||
|
||||
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 `validated addresses match the key ios builds`() {
|
||||
assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("wss://relay.example.com"))
|
||||
assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("wss://relay.example.com:443"))
|
||||
assertEquals("relay.example.com:8443", RelayDirectory.validatedDirectoryAddress("wss://relay.example.com:8443"))
|
||||
assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("wss://Relay.Example.Com/"))
|
||||
assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("relay.example.com"))
|
||||
assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("https://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 `an endpoint listed at two different coordinates rejects the file`() {
|
||||
// One endpoint cannot truthfully occupy two coordinates. iOS rejects the
|
||||
// whole file rather than letting row order choose which location clients
|
||||
// trust; the earlier first-row-wins behavior is gone with it.
|
||||
val rejected = RelayDirectory.validatedEntries(
|
||||
(header +
|
||||
"relay.example.com,10.0,20.0\n" +
|
||||
"relay.example.com:443,50.0,60.0\n").toByteArray(),
|
||||
minimumEntries = 1
|
||||
)
|
||||
assertNull(rejected)
|
||||
}
|
||||
|
||||
@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"))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,253 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the directory validation ported from GeoRelayDirectory.validatedEntries and
|
||||
* validatedDirectoryAddress. The rules are part of the cross-client contract: a file
|
||||
* one platform accepts and the other rejects splits the two relay selections at every
|
||||
* geohash at once, which is a larger divergence than the two-file split #914 closed.
|
||||
* Every rejection here is a whole-file rejection; the caller keeps its previous copy.
|
||||
*/
|
||||
class RelayDirectoryValidationTest {
|
||||
|
||||
private val header = "Relay URL,Latitude,Longitude\n"
|
||||
|
||||
private fun validate(csv: String, minimumEntries: Int = 1, baseline: Set<RelayDirectory.RelayInfo>? = null) =
|
||||
RelayDirectory.validatedEntries(csv.toByteArray(), minimumEntries, baseline)
|
||||
|
||||
// MARK: file-level rules
|
||||
|
||||
@Test
|
||||
fun `an empty file is rejected`() {
|
||||
assertNull(RelayDirectory.validatedEntries(ByteArray(0), minimumEntries = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file with only a header is rejected`() {
|
||||
assertNull(validate(header))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both header forms ios accepts parse here`() {
|
||||
assertNotNull(validate("Relay URL,Latitude,Longitude\nrelay-a.example,1.0,2.0\n"))
|
||||
assertNotNull(validate("relay url,lat,lon\nrelay-a.example,1.0,2.0\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a header ios rejects rejects the file here`() {
|
||||
assertNull(validate("Relay URL,Lat,Long\nrelay-a.example,1.0,2.0\n"))
|
||||
assertNull(validate("url,latitude,longitude\nrelay-a.example,1.0,2.0\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single byte order mark is stripped the way ios strips it`() {
|
||||
// Foundation's UTF-8 decode removes one leading BOM before iOS's BOM
|
||||
// check runs; only a doubled BOM reaches the check. Verified against the
|
||||
// real Swift code over these exact bytes.
|
||||
assertNotNull(validate("" + header + "relay-a.example,1.0,2.0\n"))
|
||||
assertNull(validate("" + header + "relay-a.example,1.0,2.0\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `percent escapes decode into the host key the way ios decodes them`() {
|
||||
assertEquals("relay.example", RelayDirectory.validatedDirectoryAddress("re%6Cay.example"))
|
||||
assertEquals("a.b.example", RelayDirectory.validatedDirectoryAddress("a%2Eb.example"))
|
||||
assertNull("invalid escape", RelayDirectory.validatedDirectoryAddress("wss://h%GGx.example"))
|
||||
assertNull("truncated escape", RelayDirectory.validatedDirectoryAddress("wss://hx.example%2"))
|
||||
assertNull("escape decoding to a query", RelayDirectory.validatedDirectoryAddress("wss://h%3Fx.example"))
|
||||
assertNull("escape decoding to non-ascii", RelayDirectory.validatedDirectoryAddress("re%C3%A9seau.example"))
|
||||
// iOS decodes after structural parsing, so a decoded colon stays in the
|
||||
// host and fails the label screen; decoded first it would become a port.
|
||||
assertNull("escape decoding to a colon", RelayDirectory.validatedDirectoryAddress("relay-a.example%3A8443"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hex coordinates parse the way swift parses them`() {
|
||||
val plain = validate(header + "relay-a.example,0x10,2.0\n")
|
||||
assertNotNull(plain)
|
||||
assertEquals(16.0, plain!![0].latitude, 0.0)
|
||||
val hexDigitTail = validate(header + "relay-a.example,0x1d,2.0\n")
|
||||
assertNotNull("d is a hex digit, not a suffix", hexDigitTail)
|
||||
assertEquals(29.0, hexDigitTail!![0].latitude, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signed zero is one coordinate and the last row's bits are kept`() {
|
||||
val entries = validate(header + "z.example,0.0,1.0\n" + "z.example,-0.0,1.0\n")
|
||||
assertNotNull("swift's == treats -0.0 and 0.0 as the same coordinate", entries)
|
||||
assertEquals(1, entries!!.size)
|
||||
assertEquals((-0.0).toRawBits(), entries[0].latitude.toRawBits())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every punycode label is rejected`() {
|
||||
// iOS IDNA-decodes xn-- labels: valid punycode becomes non-ASCII and is
|
||||
// rejected, and most invalid forms fail its parse, both pinned in the
|
||||
// battery. Foundation lets a few exotic invalid forms through
|
||||
// literally; this port rejects every xn-- label instead, stricter in
|
||||
// the safe direction, measured by the fuzz round.
|
||||
assertNull(RelayDirectory.validatedDirectoryAddress("xn--bcher-kva.example"))
|
||||
assertNull(RelayDirectory.validatedDirectoryAddress("xn--x.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hosts uri refuses but ios accepts are salvaged by the fallback`() {
|
||||
// java.net.URI follows RFC 2396 and returns no host when the final
|
||||
// label starts with a digit, or when a trailing colon carries no port;
|
||||
// iOS's RFC 3986 parser accepts both. The plain-authority fallback
|
||||
// covers exactly these shapes.
|
||||
assertEquals("b.08relay", RelayDirectory.validatedDirectoryAddress("https://b.08relay"))
|
||||
assertEquals("relay.example.1", RelayDirectory.validatedDirectoryAddress("relay.example.1:"))
|
||||
assertEquals("d.7ex:8443", RelayDirectory.validatedDirectoryAddress("wss://d.7ex:8443"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid utf8 rejects the file`() {
|
||||
assertNull(RelayDirectory.validatedEntries(byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0x41), minimumEntries = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file over the byte cap is rejected`() {
|
||||
val oversized = ByteArray(RelayDirectory.MAX_DIRECTORY_BYTES + 1) { 'a'.code.toByte() }
|
||||
assertNull(RelayDirectory.validatedEntries(oversized, minimumEntries = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `more rows than the row cap rejects the file`() {
|
||||
val rows = buildString {
|
||||
append(header)
|
||||
repeat(RelayDirectory.MAX_DIRECTORY_ROWS + 1) { append("relay-a.example,1.0,2.0\n") }
|
||||
}
|
||||
assertNull(validate(rows))
|
||||
}
|
||||
|
||||
// MARK: row-level rules, each rejecting the whole file
|
||||
|
||||
@Test
|
||||
fun `one malformed row rejects the whole file`() {
|
||||
val csv = header +
|
||||
"relay-a.example,1.0,2.0\n" +
|
||||
"relay-b.example,2.0\n" +
|
||||
"relay-c.example,3.0,4.0\n"
|
||||
assertNull(validate(csv))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an out of range coordinate rejects the whole file`() {
|
||||
assertNull(validate(header + "relay-a.example,91.0,2.0\n"))
|
||||
assertNull(validate(header + "relay-a.example,1.0,181.0\n"))
|
||||
assertNull(validate(header + "relay-a.example,abc,2.0\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a coordinate spelling only java parses rejects the file`() {
|
||||
// Double.parseDouble takes "1.5f"; Swift's Double(String) does not. Both
|
||||
// platforms must refuse the row, or one keeps a file the other drops.
|
||||
assertNull(validate(header + "relay-a.example,1.5f,2.0\n"))
|
||||
assertNull(validate(header + "relay-a.example,1.0,2.0d\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row with a rejected host rejects the whole file`() {
|
||||
val csv = header +
|
||||
"relay-a.example,1.0,2.0\n" +
|
||||
"localhost,1.0,2.0\n"
|
||||
assertNull(validate(csv))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hosts ios rejects are rejected here`() {
|
||||
val rejected = mapOf(
|
||||
"wss://relay.example.com/path" to "path beyond /",
|
||||
"wss://user@relay.example.com" to "userinfo",
|
||||
"wss://relay.example.com?x=1" to "query",
|
||||
"wss://relay.example.com#frag" to "fragment",
|
||||
"ws://relay.example.com" to "scheme other than wss or https",
|
||||
"localhost" to "localhost",
|
||||
"node.local" to ".local",
|
||||
"svc.internal" to ".internal",
|
||||
"a.localhost" to ".localhost",
|
||||
"singlelabel" to "single label",
|
||||
"192.0.2.7" to "all-numeric labels",
|
||||
"réseau.example" to "non-ascii",
|
||||
"-bad.example" to "label starting with hyphen",
|
||||
"bad-.example" to "label ending with hyphen",
|
||||
"${"a".repeat(64)}.example" to "label over 63 chars",
|
||||
"${(1..4).joinToString(".") { "a".repeat(63) }}.ex" to "host over 253 chars",
|
||||
"relay.example.com:0" to "port below 1",
|
||||
"relay.example.com:70000" to "port above 65535",
|
||||
"relay.example." to "trailing dot"
|
||||
)
|
||||
for ((raw, reason) in rejected) {
|
||||
assertNull(reason, RelayDirectory.validatedDirectoryAddress(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: floors and the hijack guard
|
||||
|
||||
@Test
|
||||
fun `a remote file below the entry floor is rejected`() {
|
||||
val csv = header + "relay-a.example,1.0,2.0\n"
|
||||
assertNull(validate(csv, minimumEntries = RelayDirectory.MIN_REMOTE_ENTRIES))
|
||||
assertNotNull(validate(csv, minimumEntries = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a download keeping less than half of the known entries is rejected`() {
|
||||
val baseline = setOf(
|
||||
RelayDirectory.RelayInfo("wss://base-a.example", 1.0, 1.0),
|
||||
RelayDirectory.RelayInfo("wss://base-b.example", 2.0, 2.0),
|
||||
RelayDirectory.RelayInfo("wss://base-c.example", 3.0, 3.0),
|
||||
RelayDirectory.RelayInfo("wss://base-d.example", 4.0, 4.0)
|
||||
)
|
||||
val keepsTwo = header +
|
||||
"base-a.example,1.0,1.0\n" +
|
||||
"base-b.example,2.0,2.0\n" +
|
||||
"fresh-a.example,5.0,5.0\n"
|
||||
assertNotNull(validate(keepsTwo, baseline = baseline))
|
||||
|
||||
val keepsOne = header +
|
||||
"base-a.example,1.0,1.0\n" +
|
||||
"fresh-a.example,5.0,5.0\n" +
|
||||
"fresh-b.example,6.0,6.0\n"
|
||||
assertNull(validate(keepsOne, baseline = baseline))
|
||||
}
|
||||
|
||||
// MARK: the shipped asset
|
||||
|
||||
@Test
|
||||
fun `the bundled asset passes the same validation a download must pass`() {
|
||||
// fetch-georelays.yml rewrites the bundled asset every week, so this
|
||||
// test checks validity, not contents: whatever the job ships must pass
|
||||
// the same validation a download must pass. The exact-count check
|
||||
// lives on the fixed snapshot below.
|
||||
val asset = listOf(
|
||||
File("src/main/assets/nostr_relays.csv"),
|
||||
File("app/src/main/assets/nostr_relays.csv")
|
||||
).firstOrNull { it.isFile } ?: error("bundled relay asset not found")
|
||||
val entries = RelayDirectory.validatedEntries(
|
||||
asset.readBytes(),
|
||||
minimumEntries = RelayDirectory.MIN_REMOTE_ENTRIES
|
||||
)
|
||||
assertNotNull("the bundled asset must pass validation", entries)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fixed snapshot of the directory yields the exact entry count`() {
|
||||
// A copy of online_relays_gps.csv taken 2026-08-30, 441 rows collapsing
|
||||
// to 326 entries. This file never changes, so a different count here is
|
||||
// a change in the validator, never a change in the data.
|
||||
val snapshot = listOf(
|
||||
File("src/test/resources/nostr_relays_snapshot.csv"),
|
||||
File("app/src/test/resources/nostr_relays_snapshot.csv")
|
||||
).firstOrNull { it.isFile } ?: error("relay snapshot fixture not found")
|
||||
val entries = RelayDirectory.validatedEntries(snapshot.readBytes(), minimumEntries = 1)
|
||||
assertNotNull(entries)
|
||||
assertEquals(326, entries!!.size)
|
||||
}
|
||||
}
|
||||
442
app/src/test/resources/nostr_relays_snapshot.csv
Normal file
442
app/src/test/resources/nostr_relays_snapshot.csv
Normal file
@ -0,0 +1,442 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
armada.sharegap.net,43.6532,-79.3832
|
||||
nostr.chaima.info,51.5072,-0.127586
|
||||
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
|
||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
nostr.whitenode45.ddns.net,40.55,-74.4758
|
||||
nostr.carroarmato0.be:443,50.914,3.21378
|
||||
cdn.satellite.earth,40.8302,-74.1299
|
||||
relay2.veganostr.com,60.1699,24.9384
|
||||
relay.layer.systems:443,49.0291,8.35695
|
||||
relay0.gfcom.info,13.7653,100.647
|
||||
relay.mmwaves.de:443,48.8575,2.35138
|
||||
offchain.pub,39.1585,-94.5728
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
staging.yabu.me,35.6092,139.73
|
||||
nostr.overpay.com,29.7449,-95.5343
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
treuzkas.branruz.com,48.8575,2.35138
|
||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
node.kommonzenze.de,49.4521,11.0767
|
||||
nostr.thalheim.io:443,60.1699,24.9384
|
||||
soloco.nl,43.6532,-79.3832
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
nostr-relay.zimage.com,34.0549,-118.243
|
||||
public.crostr.com:443,43.6532,-79.3832
|
||||
nostr.sathoarder.com:443,48.5734,7.75211
|
||||
relay.angor.io,48.1046,11.6002
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
relay.kaleidoswap.com,50.8476,4.35717
|
||||
relay.libernet.app:443,43.6532,-79.3832
|
||||
relay.homeinhk.xyz,35.694,139.754
|
||||
relay.manneken.brussels,49.4543,11.0746
|
||||
nostr.spicyz.io:443,43.6532,-79.3832
|
||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||
relay.loveisbitcoin.com,43.6532,-79.3832
|
||||
relay.angor.io:443,48.1046,11.6002
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||
nrs-01.darkcloudarcade.com,39.0997,-94.5786
|
||||
relay.endfiat.money:443,59.3327,18.0656
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
rele.speyhard.fi,51.5072,-0.127586
|
||||
relay.froth.zone,60.1699,24.9384
|
||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||
nrl.ceskar.xyz,50.5145,16.0119
|
||||
rilo.nostria.app,43.6532,-79.3832
|
||||
nostr.overmind.lol:443,43.6532,-79.3832
|
||||
nostr.snowbla.de:443,50.4754,12.3683
|
||||
nostrrelay.taylorperron.com,45.5029,-73.5723
|
||||
chorus.pjv.me,45.5201,-122.99
|
||||
relay.nostr.place,43.6532,-79.3832
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
nostr.girino.org:443,43.6532,-79.3832
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
ec2.f7z.io,60.1699,24.9384
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
relay.mccormick.cx:443,52.3563,4.95714
|
||||
relay.momostr.pink,43.6532,-79.3832
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
conduitl2.fly.dev,37.7648,-122.432
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
relay.veganostr.com,60.1699,24.9384
|
||||
relay.minibolt.info:443,43.6532,-79.3832
|
||||
adre.su,59.9311,30.3609
|
||||
bitcoinostr.duckdns.org,41.1976,1.11167
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr.planix.org,43.6532,-79.3832
|
||||
relay.satsmarkt.club,52.6907,4.8181
|
||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||
aeon.libretechsystems.xyz,55.486,9.86577
|
||||
testnet.samt.st,43.6532,-79.3832
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||
shu01.shugur.net,21.4902,39.2246
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay2.angor.io:443,48.1046,11.6002
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832
|
||||
wot.nostr.party,36.1659,-86.7844
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostr.wild-vibes.ts.net,48.8566,2.35222
|
||||
relay.nostr.com,50.1109,8.68213
|
||||
nostr.iskarion.ddns.net,43.3076,-2.95421
|
||||
relay-dev.satlantis.io,39.0438,-77.4874
|
||||
relay.sovereignresonance.org,48.9006,2.25929
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
relay.aidatanorge.no,43.6532,-79.3832
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
relay.klabo.world,47.2343,-119.853
|
||||
nostr.data.haus:443,50.4754,12.3683
|
||||
testr.nymble.world,40.8054,-74.0241
|
||||
relay.inforsupports.com,43.6532,-79.3832
|
||||
relay.nostrmap.net:443,60.1699,24.9384
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
dev-relay.nostreon.com,60.1699,24.9384
|
||||
nostr.islandarea.net:443,35.4669,-97.6473
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
relay.bowlafterbowl.com,32.9483,-96.7299
|
||||
nostr.quali.chat:443,60.1699,24.9384
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
r.0kb.io,32.789,-96.7989
|
||||
nostr.notribe.net:443,40.8302,-74.1299
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
nostrelites.org,34.9582,-81.9907
|
||||
nostr.hoppe-relay.it.com,42.8864,-78.8784
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
nostr.christiansass.de,51.7634,7.8887
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
relayone.geektank.ai,39.0997,-94.5786
|
||||
relay.dreamith.to:443,43.6532,-79.3832
|
||||
nostr.liberty.fans,36.8767,-89.5879
|
||||
wot.makenomistakes.ca,43.7064,-79.3986
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
nostr.ac,38.958,-77.3592
|
||||
ribo.us.nostria.app,43.6532,-79.3832
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
relay.chorus.community:443,48.5333,10.7
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
relay.agorist.space:443,52.3734,4.89406
|
||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||
relay.keykeeper.world,40.7824,-74.0711
|
||||
relay.getvia.xyz,60.1699,24.9384
|
||||
relay.nuts.cash,52.3676,4.90414
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
relay.fountain.fm:443,43.6532,-79.3832
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
nostr-02.uid.ovh,50.9871,2.12554
|
||||
relay.lanavault.space:443,60.1699,24.9384
|
||||
nostr.carroarmato0.be,50.914,3.21378
|
||||
nexus.libernet.app:443,43.6532,-79.3832
|
||||
relay.artio.inf.unibe.ch,46.9501,7.43678
|
||||
blossom.gnostr.cloud,43.6532,-79.3832
|
||||
relay.binaryrobot.com,43.6532,-79.3832
|
||||
relay.earthly.city,34.1749,-118.54
|
||||
nostr.hifish.org,47.4244,8.57658
|
||||
offchain.pub:443,39.1585,-94.5728
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
strfry.openhoofd.nl:443,51.5717,3.70417
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
strfry.ymir.cloud,43.6532,-79.3832
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
relay.directsponsor.net,42.8864,-78.8784
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
antiprimal.net,43.6532,-79.3832
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
inbox.scuba323.com,40.8218,-74.45
|
||||
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
nostr.davenov.com,50.1109,8.68213
|
||||
relay.trotters.cc:443,43.6532,-79.3832
|
||||
nostr.plantroon.com:443,50.1013,8.62643
|
||||
relay.nostreon.com,60.1699,24.9384
|
||||
nostr.easycryptosend.it,43.6532,-79.3832
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr.purpura.cloud,43.6532,-79.3832
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
nostr.mifen.me,43.6532,-79.3832
|
||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
relay-dev.satlantis.io:443,39.0438,-77.4874
|
||||
relay.satlantis.io,39.0438,-77.4874
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
relay.liberbitworld.org,43.6532,-79.3832
|
||||
relay.olas.app:443,60.1699,24.9384
|
||||
no.str.cr,8.96171,-83.5246
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
wot.rejecttheframe.xyz,43.6532,-79.3832
|
||||
relay.nostriot.com:443,41.5695,-83.9786
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
nostr-01.uid.ovh,50.9871,2.12554
|
||||
relay.openresist.com:443,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.internationalright-wing.org,-22.5022,-48.7114
|
||||
nostr.myshosholoza.co.za:443,52.3676,4.90414
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
21milionidinostr.duckdns.org,41.8967,12.4822
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
nostr.mom,50.4754,12.3683
|
||||
relay.decentralia.fr,48.122,11.589
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
relay.underorion.se,50.1109,8.68213
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
relay.qstr.app,51.5072,-0.127586
|
||||
relay.cyberguy.fyi,52.6907,4.8181
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
relayone.soundhsa.com:443,39.0997,-94.5786
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||
relay.44billion.net,43.6532,-79.3832
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
articles.layer3.news:443,37.3387,-121.885
|
||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||
relay.ohstr.com:443,43.6532,-79.3832
|
||||
00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
purplerelay.com:443,43.6532,-79.3832
|
||||
nostr-pr02.redscrypt.org,52.3676,4.90414
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
kasztanowa.bieda.it,43.6532,-79.3832
|
||||
relay.flashapp.me,43.6548,-79.3885
|
||||
relay.typedcypher.com,51.5072,-0.127586
|
||||
nostr.bond,50.1109,8.68213
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.mostro.network:443,40.8302,-74.1299
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
relay.chorus.community,48.5333,10.7
|
||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||
syb.lol:443,34.0549,-118.243
|
||||
relay.dyne.org,49.0291,8.35705
|
||||
btc.klendazu.com,41.2861,1.24993
|
||||
wot.nostr.place,43.6532,-79.3832
|
||||
relay.openresist.com,43.6532,-79.3832
|
||||
rilo.nostria.app:443,43.6532,-79.3832
|
||||
no.str.cr:443,8.96171,-83.5246
|
||||
relay.mostr.pub:443,43.6532,-79.3832
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||
relay.artx.market:443,43.6548,-79.3885
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
relay.novospes.com,43.6532,-79.3832
|
||||
relay.nostr-check.me,43.6532,-79.3832
|
||||
nostr.computingcache.com,34.0356,-118.442
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
relay.bornheimer.app,51.5072,-0.127586
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
nostr.twinkle.lol,51.902,7.6657
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
nostr.hifish.org:443,47.4244,8.57658
|
||||
relay.cypherflow.ai:443,48.8575,2.35138
|
||||
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||
nostr.na.social:443,43.6532,-79.3832
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.mypathtofire.de,42.8864,-78.8784
|
||||
public.crostr.com,43.6532,-79.3832
|
||||
relay.olas.app,60.1699,24.9384
|
||||
relay.agora.social,50.7383,15.0648
|
||||
ribo.nostria.app,43.6532,-79.3832
|
||||
relay.lab.rytswd.com,49.4543,11.0746
|
||||
relay.ditto.pub:443,43.6532,-79.3832
|
||||
porchlight.social,43.6532,-79.3832
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||
relay.nearhood.co.uk,51.5134,-0.0890675
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr.novacisko.cz,52.2026,20.9397
|
||||
prl.plus,55.7628,37.5983
|
||||
bruh.samt.st,43.6532,-79.3832
|
||||
strfry.openhoofd.nl,51.5717,3.70417
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
nip85.nosfabrica.com,39.0997,-94.5786
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
relay.scuba323.com,40.8218,-74.45
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
relay.mmwaves.de,48.8575,2.35138
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
strfry.shock.network:443,39.0438,-77.4874
|
||||
nostr.snowbla.de,50.4754,12.3683
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
wot.utxo.one,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
mostro-p2p.tech,50.1109,8.68213
|
||||
basspistol.org,49.0291,8.35696
|
||||
ribo.nostria.app:443,43.6532,-79.3832
|
||||
chorus.mikedilger.com:444,-36.8906,174.794
|
||||
nostr.oxtr.dev:443,50.4754,12.3683
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
||||
relay.nmail.li,50.9871,2.12554
|
||||
nostr-relay.corb.net,39.6478,-104.988
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
spamspamspamspam.rest,43.6532,-79.3832
|
||||
relay1.gfcom.info,13.9215,100.538
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
nostr.relay.hedwig.sh,60.1699,24.9384
|
||||
relay.veganostr.com:443,60.1699,24.9384
|
||||
relay.wavefunc.live:443,41.8781,-87.6298
|
||||
nostr.mikoshi.de,52.52,13.405
|
||||
syb.lol,34.0549,-118.243
|
||||
relay1.nostrchat.io,60.1699,24.9384
|
||||
nostr.wecsats.io:443,43.6532,-79.3832
|
||||
nostr.chaima.info:443,51.5072,-0.127586
|
||||
nostr.azzamo.net:443,52.2633,21.0283
|
||||
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
|
||||
nostr.unkn0wn.world,46.8499,9.53287
|
||||
relayone.soundhsa.com,39.0997,-94.5786
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
nostrelay.circum.space,52.6907,4.8181
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
relay.kalcafe.xyz,37.3986,-121.964
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
relay.kilombino.com,43.6532,-79.3832
|
||||
nos.lol:443,50.4754,12.3683
|
||||
nos.lol,50.4754,12.3683
|
||||
relay.nostr.place:443,43.6532,-79.3832
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
relay.pyramid.li,47.4093,8.46503
|
||||
relay.typedcypher.com:443,51.5072,-0.127586
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
relay.plebeian.market:443,50.1109,8.68213
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
nostrcity-club.fly.dev,37.7648,-122.432
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
weboftrust.libretechsystems.xyz,55.4724,9.87335
|
||||
nostr-relay.corb.net:443,39.6478,-104.988
|
||||
wheat.happytavern.co,43.6532,-79.3832
|
||||
relay.mappingbitcoin.com,43.6532,-79.3832
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
dev.relay.edufeed.org,49.4521,11.0767
|
||||
myvoiceourstory.org,37.3598,-121.981
|
||||
relay.stickeroo.is-cool.dev,37.3387,-121.885
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
x.kojira.io:443,43.6532,-79.3832
|
||||
nostrelay.circum.space:443,52.6907,4.8181
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
relay.mrmave.work,43.6532,-79.3832
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
hol.is,43.6532,-79.3832
|
||||
ribo.eu.nostria.app,43.6532,-79.3832
|
||||
nostr.yutakobayashi.com,43.6532,-79.3832
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
communities.nos.social,40.8302,-74.1299
|
||||
relay.solife.me,43.6532,-79.3832
|
||||
yabu.me,35.6092,139.73
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
nostr.tac.lol:443,47.4748,-122.273
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
nostrride.io,37.3986,-121.964
|
||||
r.0kb.io:443,32.789,-96.7989
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
relay.sincensura.org,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||
|
@ -36,6 +36,24 @@ 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`.
|
||||
|
||||
Directory acceptance is part of the same contract. Both platforms validate a
|
||||
directory file with the same rules (exact header, per-row host and coordinate
|
||||
checks, size, row, and entry caps, and a minimum overlap with the previous
|
||||
entries for downloads) and reject a violating file whole, keeping the previous
|
||||
copy. A file one platform accepts and the other rejects splits the two relay
|
||||
selections at every geohash at once. `RelayDirectoryValidationTest` covers the
|
||||
Android side; iOS implements the same rules in
|
||||
`GeoRelayDirectory.validatedEntries`.
|
||||
|
||||
## Rewrite acceptance gate
|
||||
|
||||
From a configured Android development environment, run:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user