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
@@ -7,10 +7,10 @@ import java.util.concurrent.TimeUnit
/**
* Resolves a Lightning node pubkey to a human-readable alias.
* Tries 1ml.com first, falls back to mempool.space.
* Tries mempool.space first, falls back to 1ml.com.
* Returns null if neither source has the node or both fail.
*/
class NodeAliasService {
class NodeAliasClient {
private val client = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
@@ -34,15 +34,15 @@ class NodeAliasService {
suspend fun resolveAlias(pubkey: String): String? {
if (pubkey.isBlank()) return null
// 1. Try 1ml.com
runCatching { oneMlApi.getNode(pubkey) }
// 1. Try mempool.space
runCatching { mempoolApi.getNode(pubkey) }
.onSuccess { response ->
val alias = response.alias?.takeIf { it.isNotBlank() }
if (alias != null) return alias
}
// 2. Fall back to mempool.space
runCatching { mempoolApi.getNode(pubkey) }
// 2. Fall back to 1ml.com
runCatching { oneMlApi.getNode(pubkey) }
.onSuccess { response ->
val alias = response.alias?.takeIf { it.isNotBlank() }
if (alias != null) return alias
@@ -10,10 +10,10 @@ import kotlinx.coroutines.flow.StateFlow
class LNbitsNodeAliasRepository(
private val service: NodeAliasService
) : NodeAliasRepository {
override val aliases: StateFlow<Map<String, String>>
get() = service.aliases
override suspend fun resolve(pubkey: String): String? =
service.resolve(pubkey)
override suspend fun warmUp() = service.warmUp()
}
@@ -1,10 +1,8 @@
package com.bitcointxoko.gudariwallet.data
import android.content.Context
import android.net.Uri
import android.util.Log
import com.bitcointxoko.gudariwallet.api.*
import com.bitcointxoko.gudariwallet.api.LNbitsClient.httpClient
import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.util.LnurlBech32
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
@@ -15,9 +13,6 @@ private const val TAG = "LNbitsWalletRepo"
class LNbitsWalletRepository(
private val api: LNbitsApi,
private val secrets: SecretStore,
private val context : Context,
private val nodeAliasService: com.bitcointxoko.gudariwallet.service.NodeAliasService =
com.bitcointxoko.gudariwallet.service.NodeAliasService(httpClient, context),
) : WalletRepository {
// ── Balance ───────────────────────────────────────────────────────────────
@@ -157,7 +152,7 @@ class LNbitsWalletRepository(
append(amountMsat)
if (!comment.isNullOrBlank()) {
append("&comment=")
append(android.net.Uri.encode(comment))
append(Uri.encode(comment))
}
}
Log.d(TAG, "LNURL [FETCH INVOICE] callback=$callbackUrl")
@@ -0,0 +1,68 @@
package com.bitcointxoko.gudariwallet.data
import android.content.Context
import android.util.Log
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.data.db.NodeAliasCacheEntity
import com.bitcointxoko.gudariwallet.util.WalletConstants
private const val TAG = "NodeAliasCacheRepository"
class NodeAliasCacheRepository(context: Context) {
private val dao = AppDatabase.getInstance(context).nodeAliasCacheDao()
/**
* Returns a cached alias for [pubkey] if it exists and is within
* [WalletConstants.ALIAS_CACHE_TTL_MS]. Returns null on miss or stale.
*/
suspend fun get(pubkey: String): String? {
val entry = dao.getByPubkey(pubkey) ?: run {
Log.d(TAG, "ALIAS [CACHE MISS ] $pubkey")
return null
}
val ageMs = System.currentTimeMillis() - entry.savedAt
return if (ageMs < WalletConstants.ALIAS_CACHE_TTL_MS) {
Log.d(TAG, "ALIAS [CACHE HIT ] $pubkey\"${entry.alias}\" (age ${ageMs / 1000}s)")
entry.alias
} else {
Log.d(TAG, "ALIAS [CACHE STALE] $pubkey (age ${ageMs / 1000}s) — will refetch")
null
}
}
/** Upsert an alias into the Room cache. */
suspend fun put(pubkey: String, alias: String) {
dao.insert(
NodeAliasCacheEntity(
pubkey = pubkey,
alias = alias,
savedAt = System.currentTimeMillis()
)
)
Log.d(TAG, "ALIAS [PERSIST ] $pubkey\"$alias\"")
}
/**
* Load all non-stale entries from Room as a plain map.
* Used to seed the StateFlow on startup — replaces [loadPersistedCache].
*/
suspend fun loadAll(): Map<String, String> {
val now = System.currentTimeMillis()
return dao.getAll()
.filter { now - it.savedAt < WalletConstants.ALIAS_PERSIST_TTL_MS }
.associate { it.pubkey to it.alias }
.also { Log.d(TAG, "ALIAS [PERSIST ] Loaded ${it.size} non-stale entries from Room") }
}
/** Remove a single entry. */
suspend fun remove(pubkey: String) {
dao.deleteByPubkey(pubkey)
}
/** Wipe the entire alias cache — mirrors [clearPersistedCache]. */
suspend fun clearAll() {
dao.deleteAll()
Log.d(TAG, "ALIAS [PERSIST ] Cache cleared")
}
}
@@ -18,4 +18,5 @@ interface NodeAliasRepository {
* Results are cached — repeated calls for the same pubkey are cheap.
*/
suspend fun resolve(pubkey: String): String?
suspend fun warmUp()
}
@@ -9,8 +9,12 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [PaymentRecordEntity::class, LnurlCacheEntity::class],
version = 2,
entities = [
PaymentRecordEntity::class,
LnurlCacheEntity::class,
NodeAliasCacheEntity::class, // ← added
],
version = 3, // ← bumped
exportSchema = false
)
@TypeConverters(PaymentConverters::class)
@@ -18,6 +22,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun paymentDao(): PaymentDao
abstract fun lnurlCacheDao(): LnurlCacheDao
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao // ← added
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
@@ -37,6 +42,21 @@ abstract class AppDatabase : RoomDatabase() {
}
}
private val MIGRATION_2_3 = object : Migration(2, 3) { // ← added
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `node_alias_cache` (
`pubkey` TEXT NOT NULL,
`alias` TEXT NOT NULL,
`savedAt` INTEGER NOT NULL,
PRIMARY KEY(`pubkey`)
)
""".trimIndent()
)
}
}
fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
@@ -44,7 +64,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
"gudari_wallet.db"
)
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3) // ← added
.build()
.also { INSTANCE = it }
}
@@ -0,0 +1,25 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface NodeAliasCacheDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entry: NodeAliasCacheEntity)
@Query("SELECT * FROM node_alias_cache WHERE pubkey = :pubkey LIMIT 1")
suspend fun getByPubkey(pubkey: String): NodeAliasCacheEntity?
@Query("SELECT * FROM node_alias_cache")
suspend fun getAll(): List<NodeAliasCacheEntity>
@Query("DELETE FROM node_alias_cache WHERE pubkey = :pubkey")
suspend fun deleteByPubkey(pubkey: String)
@Query("DELETE FROM node_alias_cache")
suspend fun deleteAll()
}
@@ -0,0 +1,12 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "node_alias_cache")
data class NodeAliasCacheEntity(
@PrimaryKey
val pubkey: String, // node public key
val alias: String, // resolved human-readable alias
val savedAt: Long // epoch ms — used for TTL checks
)
@@ -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
}
}
}
}
@@ -67,6 +67,7 @@ class WalletViewModel(
init {
observePaymentEvents()
fetchLightningAddress()
viewModelScope.launch { aliasRepo.warmUp() }
}
// ── WebSocket event collection ────────────────────────────────────────────
@@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.bitcointxoko.gudariwallet.api.LNbitsClient
import com.bitcointxoko.gudariwallet.api.NodeAliasClient
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
@@ -24,12 +25,12 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
val secrets = EncryptedSecretStore(app)
val baseUrl = secrets.baseUrl.ifBlank { "https://placeholder.invalid" }
val api = LNbitsClient.create(baseUrl)
val nodeAliasSvc = NodeAliasService(httpClient = LNbitsClient.httpClient, context = app)
val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app)
val lnurlCache = LnurlCacheRepository(app)
val paymentCache = PaymentCacheRepository(app)
val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc)
val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app)
val repo = LNbitsWalletRepository(api = api, secrets = secrets)
val balancePrefs = BalancePrefsStore(app)
val balanceVm = BalanceViewModel(repo, balancePrefs)
val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm)
@@ -5,10 +5,6 @@ object WalletConstants {
// ── Unit conversion ───────────────────────────────────────────────────────
const val MSAT_PER_SAT = 1_000L // millisatoshis per satoshi
// ── Polling ───────────────────────────────────────────────────────────────
const val POLL_INTERVAL_MS = 3_000L // delay between payment status checks
const val POLL_MAX_ATTEMPTS = 200 // 200 × 3s = 10 minutes max
// ── WebSocket reconnect ───────────────────────────────────────────────────
const val WS_INITIAL_BACKOFF_MS = 1_000L // first retry delay
const val WS_MAX_BACKOFF_MS = 16_000L // cap — never wait longer than this
@@ -33,9 +29,7 @@ object WalletConstants {
const val PAYMENTS_PAGE_SIZE = 20
// ── Alias cache ───────────────────────────────────────────────────────────
const val ALIAS_CACHE_TTL_MS = 3_600_000L // 1 hour — in-memory stale threshold
const val ALIAS_PREFS_NAME = "alias_cache_prefs"
const val ALIAS_PREFS_KEY = "alias_cache"
const val ALIAS_PERSIST_TTL_MS = 86_400_000L // 24 hours — drop on load if older than this
const val ALIAS_PERSIST_TTL_MS = 21 * 24 * 60 * 60 * 1000L // 21 days — drop on load if older than this
// ── LNURL metadata cache ──────────────────────────────────────────────────
const val LNURL_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000L // 7 days
// ── Payments cache ────────────────────────────────────────────────────────
@@ -48,5 +42,4 @@ object WalletConstants {
const val BALANCE_PREFS_KEY = "last_balance_sats"
const val BALANCE_PREFS_SAVED_AT = "last_balance_saved_at"
const val BALANCE_CACHE_TTL_MS = 3_600_000L // 1 hour
}