feat: migrate lnurlcache to room

This commit is contained in:
2026-06-01 22:48:39 +02:00
parent 2e0530073d
commit 38c6c6f723
6 changed files with 124 additions and 87 deletions
@@ -1,123 +1,110 @@
package com.bitcointxoko.gudariwallet.data
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.bitcointxoko.gudariwallet.api.GsonProvider
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity
import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
private const val TAG = "LnurlCacheRepository"
data class LnurlCacheEntry(
val key : String,
val response : LnurlScanResponse,
val savedAt : Long
)
class LnurlCacheRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
WalletConstants.LNURL_CACHE_PREFS_NAME, Context.MODE_PRIVATE
)
private val gson = Gson()
private val dao = AppDatabase.getInstance(context).lnurlCacheDao()
private val gson = GsonProvider.gson
// In-memory map for fast access within a session
private val memCache = mutableMapOf<String, LnurlCacheEntry>()
// Fast in-memory path — avoids a DB round-trip for keys already loaded
// this session. Mirrors the pattern used in PaymentCacheRepository.
private val memCache = mutableMapOf<String, LnurlCacheEntity>()
init { loadFromDisk() }
// ── Public API ─────────────────────────────────────────────────────────────
/**
* Returns a cached [LnurlScanResponse] for [key], or null if:
* - no entry exists
* - the entry is older than [WalletConstants.LNURL_CACHE_TTL_MS]
* - the server marked the endpoint as disposable (null is treated as true per LUD-06)
* - the server marked the endpoint as disposable (null is treated as true
* per LUD-06)
*/
fun get(key: String): LnurlScanResponse? {
val entry = memCache[key] ?: return null
val ageMs = System.currentTimeMillis() - entry.savedAt
suspend fun get(key: String): LnurlScanResponse? {
val entity = memCache[key] ?: dao.getByKey(key)?.also { memCache[key] = it }
?: return null
val ageMs = System.currentTimeMillis() - entity.savedAt
if (ageMs > WalletConstants.LNURL_CACHE_TTL_MS) {
Log.d(TAG, "LNURL [CACHE STALE] $key (age ${ageMs / 1000}s)")
memCache.remove(key)
persistToDisk()
evict(key)
return null
}
val response = deserialise(entity.responseJson) ?: run {
Log.w(TAG, "LNURL [DESER ERR ] $key — removing corrupt entry")
evict(key)
return null
}
// LUD-06: disposable == null means treat as true — do not serve from cache
val disposable = entry.response.disposable
if (disposable == null || disposable == true) {
val disposable = response.disposable
if (disposable == true) {
Log.d(TAG, "LNURL [DISPOSABLE ] $key — not serving from cache (disposable=$disposable)")
return null
}
Log.d(TAG, "LNURL [CACHE HIT ] $key (age ${ageMs / 1000}s)")
return entry.response
return response
}
/**
* Stores [response] under [key].
* Silently skips if the response is marked disposable (or disposable is null).
*/
fun put(key: String, response: LnurlScanResponse) {
suspend fun put(key: String, response: LnurlScanResponse) {
val disposable = response.disposable
if (disposable == null || disposable == true) {
if ( disposable == true) {
Log.d(TAG, "LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching")
return
}
val entry = LnurlCacheEntry(key, response, System.currentTimeMillis())
memCache[key] = entry
persistToDisk()
val entity = LnurlCacheEntity(
key = key,
responseJson = gson.toJson(response),
savedAt = System.currentTimeMillis()
)
runCatching {
dao.insert(entity)
memCache[key] = entity
Log.d(TAG, "LNURL [CACHE SET ] $key")
}
/**
* Removes the entry for [key] from both memory and disk.
* Called when a payment attempt with cached data fails, so the next
* scan will always fetch fresh metadata from the server.
*/
fun invalidate(key: String) {
if (memCache.remove(key) != null) {
persistToDisk()
Log.d(TAG, "LNURL [INVALIDATED] $key")
}
}
fun clearAll() {
memCache.clear()
prefs.edit().clear().apply()
Log.d(TAG, "LNURL [CLEARED ] all entries")
}
// ── Persistence ───────────────────────────────────────────────────────────
private fun loadFromDisk() {
val json = prefs.getString(WalletConstants.LNURL_CACHE_PREFS_KEY, null) ?: return
runCatching {
val type = object : TypeToken<List<LnurlCacheEntry>>() {}.type
val list: List<LnurlCacheEntry> = gson.fromJson(json, type)
val now = System.currentTimeMillis()
var loaded = 0
list.forEach { entry ->
if (now - entry.savedAt < WalletConstants.LNURL_CACHE_TTL_MS) {
memCache[entry.key] = entry
loaded++
}
}
Log.d(TAG, "LNURL [LOADED ] $loaded valid entries from disk (${list.size - loaded} expired)")
}.onFailure {
Log.w(TAG, "LNURL [LOAD ERR ] ${it.message}")
}
}
private fun persistToDisk() {
runCatching {
prefs.edit()
.putString(WalletConstants.LNURL_CACHE_PREFS_KEY, gson.toJson(memCache.values.toList()))
.apply()
}.onFailure {
Log.w(TAG, "LNURL [PERSIST ERR] ${it.message}")
}
}
/**
* Removes the entry for [key] from both memory and DB.
* Called when a payment attempt with cached data fails, so the next
* scan will always fetch fresh metadata from the server.
*/
suspend fun invalidate(key: String) {
evict(key)
Log.d(TAG, "LNURL [INVALIDATED] $key")
}
suspend fun clearAll() {
memCache.clear()
runCatching { dao.deleteAll() }
.onFailure { Log.w(TAG, "LNURL [CLEAR ERR ] ${it.message}") }
Log.d(TAG, "LNURL [CLEARED ] all entries")
}
// ── Helpers ────────────────────────────────────────────────────────────────
private suspend fun evict(key: String) {
memCache.remove(key)
runCatching { dao.deleteByKey(key) }
}
private fun deserialise(json: String): LnurlScanResponse? =
runCatching { gson.fromJson(json, LnurlScanResponse::class.java) }
.getOrNull()
}
@@ -5,27 +5,48 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [PaymentRecordEntity::class],
version = 1,
entities = [PaymentRecordEntity::class, LnurlCacheEntity::class],
version = 2,
exportSchema = false
)
@TypeConverters(PaymentConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun paymentDao(): PaymentDao
abstract fun lnurlCacheDao(): LnurlCacheDao
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `lnurl_cache` (
`key` TEXT NOT NULL,
`responseJson` TEXT NOT NULL,
`savedAt` INTEGER NOT NULL,
PRIMARY KEY(`key`)
)
""".trimIndent()
)
}
}
fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"gudari_wallet.db"
).build().also { INSTANCE = it }
)
.addMigrations(MIGRATION_1_2)
.build()
.also { INSTANCE = it }
}
}
}
@@ -0,0 +1,22 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface LnurlCacheDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entry: LnurlCacheEntity)
@Query("SELECT * FROM lnurl_cache WHERE `key` = :key LIMIT 1")
suspend fun getByKey(key: String): LnurlCacheEntity?
@Query("DELETE FROM lnurl_cache WHERE `key` = :key")
suspend fun deleteByKey(key: String)
@Query("DELETE FROM lnurl_cache")
suspend fun deleteAll()
}
@@ -0,0 +1,12 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "lnurl_cache")
data class LnurlCacheEntity(
@PrimaryKey
val key: String, // the decoded LNURL / lightning-address string
val responseJson: String, // full LnurlScanResponse serialised as JSON
val savedAt: Long // epoch ms — used for TTL check
)
@@ -57,7 +57,6 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
return WalletViewModel(
repo,
aliasRepo = aliasRepo,
lnurlCache = lnurlCache,
paymentCache = paymentCache,
balancePrefs = balancePrefs,
balanceVm = balanceVm,
@@ -36,10 +36,9 @@ object WalletConstants {
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
// ── LNURL metadata cache ──────────────────────────────────────────────────
const val LNURL_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000L // 7 days
// ── Payments cache ────────────────────────────────────────────────────────
// const val PAYMENTS_PREFS_NAME = "payment_cache_prefs"
// const val PAYMENTS_PREFS_KEY = "payment_list"
// const val PAYMENTS_PREFS_SAVED_AT = "payment_list_saved_at"
const val PAYMENTS_CACHE_TTL_MS = 300_000L // 5 minutes — show stale, refresh in background
// ── Payments sync ─────────────────────────────────────────────────────────────
const val PAYMENTS_SYNC_CHECKPOINT = 100 // max payments fetched on auto-sync at startup
@@ -49,8 +48,5 @@ 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
// ── LNURL metadata cache ──────────────────────────────────────────────────
const val LNURL_CACHE_PREFS_NAME = "lnurl_cache_prefs"
const val LNURL_CACHE_PREFS_KEY = "lnurl_entries"
const val LNURL_CACHE_TTL_MS = 3_600_000L // 1 hour
}