feat: migrate nodeAliasCache to room

This commit is contained in:
2026-06-01 23:59:42 +02:00
parent 38c6c6f723
commit 93332dc683
12 changed files with 166 additions and 180 deletions
@@ -1,184 +1,54 @@
package com.bitcointxoko.gudariwallet.service
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import com.bitcointxoko.gudariwallet.api.NodeAliasClient as NodeAliasApiClient
import com.bitcointxoko.gudariwallet.data.NodeAliasCacheRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap
private const val TAG = "NodeAliasService"
class NodeAliasService(
private val httpClient : OkHttpClient,
private val context : Context
private val apiClient: NodeAliasApiClient, // ← injected, not duplicated
context: Context
) {
// ── Persistence ───────────────────────────────────────────────────────────
private val prefs: SharedPreferences = context.getSharedPreferences(
WalletConstants.ALIAS_PREFS_NAME, Context.MODE_PRIVATE
)
private val gson = Gson()
// ── In-memory alias cache ─────────────────────────────────────────────────
private data class CachedAlias(val alias: String, val fetchedAt: Long)
// Type token for Gson deserialisation
private val cacheType = object : TypeToken<Map<String, CachedAlias>>() {}.type
private val cache = ConcurrentHashMap<String, CachedAlias>()
// ── Shared alias StateFlow — all screens collect this ─────────────────────
private val repo = NodeAliasCacheRepository(context)
private val _aliases = MutableStateFlow<Map<String, String>>(emptyMap())
val aliases: StateFlow<Map<String, String>> = _aliases.asStateFlow()
// ── Init — load persisted cache from SharedPreferences ────────────────────
init {
loadPersistedCache()
suspend fun warmUp() {
_aliases.value = repo.loadAll()
}
private fun loadPersistedCache() {
val json = prefs.getString(WalletConstants.ALIAS_PREFS_KEY, null) ?: run {
Log.d(TAG, "ALIAS [PERSIST ] No persisted cache found — starting fresh")
return
}
runCatching {
val persisted: Map<String, CachedAlias> = gson.fromJson(json, cacheType)
val now = System.currentTimeMillis()
var loaded = 0
var dropped = 0
persisted.forEach { (pubkey, entry) ->
val ageMs = now - entry.fetchedAt
if (ageMs < WalletConstants.ALIAS_PERSIST_TTL_MS) {
cache[pubkey] = entry
loaded++
} else {
dropped++
Log.d(TAG, "ALIAS [PERSIST ] Dropped stale entry for $pubkey (age ${ageMs / 3_600_000}h)")
}
}
// Seed the StateFlow with everything we loaded
_aliases.value = cache.mapValues { it.value.alias }
Log.d(TAG, "ALIAS [PERSIST ] Loaded $loaded entries, dropped $dropped stale entries")
}.onFailure { e ->
Log.w(TAG, "ALIAS [PERSIST ] Failed to deserialise cache — starting fresh: ${e.message}")
}
}
private fun persistCache() {
runCatching {
val json = gson.toJson(cache)
prefs.edit().putString(WalletConstants.ALIAS_PREFS_KEY, json).apply()
Log.d(TAG, "ALIAS [PERSIST ] Wrote ${cache.size} entries to SharedPreferences")
}.onFailure { e ->
Log.w(TAG, "ALIAS [PERSIST ] Failed to write cache: ${e.message}")
}
}
// ── Public API ────────────────────────────────────────────────────────────
suspend fun resolve(pubkey: String): String? {
val now = System.currentTimeMillis()
// 1. Room cache hit
repo.get(pubkey)?.let {
Log.d(TAG, "ALIAS [CACHE HIT ] $pubkey\"$it\"")
return it
}
cache[pubkey]?.let { entry ->
if (now - entry.fetchedAt < WalletConstants.ALIAS_CACHE_TTL_MS) {
Log.d(TAG, "ALIAS [CACHE HIT ] $pubkey\"${entry.alias}\" (age ${(now - entry.fetchedAt) / 1000}s)")
return entry.alias
} else {
Log.d(TAG, "ALIAS [CACHE STALE] $pubkey (age ${(now - entry.fetchedAt) / 1000}s) — refetching")
}
} ?: Log.d(TAG, "ALIAS [CACHE MISS ] $pubkey — fetching from network")
val alias = fetchFromNetwork(pubkey)
if (alias == null) {
Log.w(TAG, "ALIAS [NOT FOUND ] $pubkey — no alias from mempool or 1ml")
// 2. Network via api/NodeAliasService (Retrofit, typed responses)
Log.d(TAG, "ALIAS [CACHE MISS] $pubkey — fetching from network")
val alias = apiClient.resolveAlias(pubkey) ?: run {
Log.w(TAG, "ALIAS [NOT FOUND ] $pubkey")
return null
}
// Update in-memory cache
cache[pubkey] = CachedAlias(alias = alias, fetchedAt = System.currentTimeMillis())
// 3. Persist to Room
repo.put(pubkey, alias)
// Publish to shared StateFlow
// 4. Publish to StateFlow
_aliases.value = _aliases.value + (pubkey to alias)
// Persist to SharedPreferences
persistCache()
Log.d(TAG, "ALIAS [API FETCHED] $pubkey\"$alias\"")
Log.d(TAG, "ALIAS [FETCHED ] $pubkey\"$alias\"")
return alias
}
/** Clears in-memory cache, StateFlow, and persisted SharedPreferences entry. */
fun clearPersistedCache() {
cache.clear()
suspend fun clearPersistedCache() {
repo.clearAll()
_aliases.value = emptyMap()
prefs.edit().remove(WalletConstants.ALIAS_PREFS_KEY).apply()
Log.d(TAG, "ALIAS [PERSIST ] Cache cleared")
}
// ── Private helpers ───────────────────────────────────────────────────────
private suspend fun fetchFromNetwork(pubkey: String): String? = withContext(Dispatchers.IO) {
Log.d(TAG, "ALIAS [TRYING ] mempool.space for $pubkey")
val mempoolResult = fetchFromMempool(pubkey)
if (mempoolResult != null) {
Log.d(TAG, "ALIAS [SOURCE ] mempool.space resolved $pubkey\"$mempoolResult\"")
return@withContext mempoolResult
}
Log.d(TAG, "ALIAS [TRYING ] 1ml.com fallback for $pubkey")
val mlResult = fetchFrom1ml(pubkey)
if (mlResult != null) {
Log.d(TAG, "ALIAS [SOURCE ] 1ml.com resolved $pubkey\"$mlResult\"")
}
mlResult
}
private fun fetchFromMempool(pubkey: String): String? {
return try {
val request = Request.Builder()
.url("https://mempool.space/api/v1/lightning/nodes/$pubkey")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body?.string() ?: return null
Regex(""""alias"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
}
} catch (e: Exception) {
Log.w(TAG, "mempool.space lookup failed for $pubkey: ${e.message}")
null
}
}
private fun fetchFrom1ml(pubkey: String): String? {
return try {
val request = Request.Builder()
.url("https://1ml.com/node/$pubkey/json")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body?.string() ?: return null
Regex(""""alias"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
}
} catch (e: Exception) {
Log.w(TAG, "1ml.com lookup failed for $pubkey: ${e.message}")
null
}
}
}
}