diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt index 865f1459..db353581 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -1,10 +1,24 @@ 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.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request /** * Loads relay coordinates from assets and provides nearest-relay lookup by geohash. @@ -12,7 +26,21 @@ import kotlin.math.* 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" 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 val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1) + + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val httpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .callTimeout(15, TimeUnit.SECONDS) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + } data class RelayInfo( val url: String, @@ -24,35 +52,35 @@ object RelayDirectory { private var initialized: Boolean = false private val relays: MutableList = mutableListOf() + private val relaysLock = Any() fun initialize(application: Application) { if (initialized) return synchronized(this) { if (initialized) return try { - val input = application.assets.open(ASSET_FILE) - BufferedReader(InputStreamReader(input)).use { reader -> - var line: String? - var numLoaded = 0 - while (true) { - line = reader.readLine() - if (line == null) break - val trimmed = line!!.trim() - if (trimmed.isEmpty()) continue - // Skip header if present - 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 - relays.add(RelayInfo(url = url, latitude = lat, longitude = lon)) - numLoaded += 1 - } - Log.i(TAG, "📥 Loaded $numLoaded relay entries from assets/$ASSET_FILE") + val downloaded = getDownloadedFile(application) + val loadedFromDownloaded = if (downloaded.exists() && downloaded.canRead()) { + loadFromFile(downloaded, sourceLabel = "downloaded") + } else { + false } + + if (!loadedFromDownloaded) { + loadFromAssets(application) + } + initialized = true + + // Trigger an immediate fetch if the data is stale (older than 24h) + ioScope.launch { + if (isStale(application)) { + fetchAndMaybeSwap(application) + } + } + + // Start periodic staleness check every minute + startPeriodicRefresh(application) } catch (e: Exception) { Log.e(TAG, "Failed to initialize RelayDirectory: ${e.message}") } @@ -63,7 +91,8 @@ object RelayDirectory { * Return up to nRelays closest relay URLs to the geohash center. */ fun closestRelaysForGeohash(geohash: String, nRelays: Int): List { - if (relays.isEmpty()) return emptyList() + val snapshot = synchronized(relaysLock) { relays.toList() } + if (snapshot.isEmpty()) return emptyList() val center = try { val c = com.bitchat.android.geohash.Geohash.decodeToCenter(geohash) c @@ -73,7 +102,7 @@ object RelayDirectory { } val (lat, lon) = center - return relays + return snapshot .asSequence() .sortedBy { haversineMeters(lat, lon, it.latitude, it.longitude) } .take(nRelays.coerceAtLeast(0)) @@ -95,6 +124,186 @@ object RelayDirectory { 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) + + private fun getDownloadedFile(application: Application): File = + File(application.filesDir, DOWNLOADED_FILE) + + private fun isStale(application: Application): Boolean { + val last = getPrefs(application).getLong(KEY_LAST_UPDATE_MS, 0L) + val now = System.currentTimeMillis() + return now - last >= ONE_DAY_MS + } + + private fun startPeriodicRefresh(application: Application) { + ioScope.launch { + while (true) { + try { + if (isStale(application)) { + fetchAndMaybeSwap(application) + } + } catch (e: Exception) { + Log.w(TAG, "Periodic refresh encountered an error: ${e.message}") + } + delay(TimeUnit.MINUTES.toMillis(1)) + } + } + } + + private fun fetchAndMaybeSwap(application: Application) { + try { + val tmpFile = File.createTempFile("relays_", ".csv", application.cacheDir) + val ok = downloadToFile(ASSET_FILE_URL, tmpFile) + if (!ok) { + Log.w(TAG, "Failed to fetch latest relays; keeping current list (will fallback to bundled if none)") + tmpFile.delete() + return + } + + val parsed = parseCsv(FileInputStream(tmpFile)) + if (parsed.isEmpty()) { + Log.w(TAG, "Downloaded relay CSV parsed to 0 entries; ignoring") + tmpFile.delete() + return + } + + val dest = getDownloadedFile(application) + tmpFile.inputStream().use { input -> + FileOutputStream(dest, false).use { output -> + input.copyTo(output) + } + } + tmpFile.delete() + + val hash = fileSha256Hex(dest) + val entries = parsed.size + + synchronized(relaysLock) { + relays.clear() + relays.addAll(parsed) + } + + getPrefs(application).edit().putLong(KEY_LAST_UPDATE_MS, System.currentTimeMillis()).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) { + Log.w(TAG, "Failed to fetch and swap relay list: ${e.message}") + } + } + + private fun downloadToFile(url: String, dest: File): Boolean { + return try { + val req = Request.Builder().url(url).get().build() + httpClient.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) { + Log.w(TAG, "HTTP ${'$'}{resp.code} when fetching $url") + return false + } + val body = resp.body ?: return false + FileOutputStream(dest).use { out -> + body.byteStream().use { input -> + input.copyTo(out) + } + } + true + } + } catch (e: Exception) { + Log.w(TAG, "Download error: ${e.message}") + false + } + } + + 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") + false + } else { + synchronized(relaysLock) { + relays.clear() + relays.addAll(list) + } + val hash = fileSha256Hex(file) + Log.i(TAG, "📄 Loaded ${list.size} relay entries from ${sourceLabel} file (${file.absolutePath}), sha256=$hash") + true + } + } catch (e: Exception) { + Log.w(TAG, "Failed loading ${sourceLabel} relay file: ${e.message}") + false + } + } + + private fun loadFromAssets(application: Application) { + val list = try { + parseCsv(application.assets.open(ASSET_FILE)) + } catch (e: Exception) { + Log.e(TAG, "Failed to open asset $ASSET_FILE: ${e.message}") + emptyList() + } + synchronized(relaysLock) { + relays.clear() + relays.addAll(list) + } + // Compute asset hash for logging + val hash = try { + application.assets.open(ASSET_FILE).use { input -> + streamSha256Hex(input) + } + } catch (e: Exception) { + "error:${'$'}{e.message}" + } + Log.i(TAG, "📦 Loaded ${list.size} relay entries from assets/$ASSET_FILE, sha256=$hash") + } + + private fun parseCsv(input: InputStream): List { + val result = mutableListOf() + 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)) + } + } + return result + } + + private fun fileSha256Hex(file: File): String = try { + FileInputStream(file).use { input -> + streamSha256Hex(input) + } + } catch (_: Exception) { "error" } + + private fun streamSha256Hex(input: InputStream): String { + val digest = MessageDigest.getInstance("SHA-256") + val buf = ByteArray(8192) + var read: Int + while (true) { + read = input.read(buf) + if (read <= 0) break + digest.update(buf, 0, read) + } + val bytes = digest.digest() + return bytes.joinToString("") { b -> + val v = b.toInt() and 0xff + val s = Integer.toHexString(v) + if (s.length == 1) "0$s" else s + } + } }