feat: migrate nodeAliasCache to room
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user