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 package com.bitcointxoko.gudariwallet.data
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.util.Log import android.util.Log
import com.bitcointxoko.gudariwallet.api.GsonProvider
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse 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.bitcointxoko.gudariwallet.util.WalletConstants
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
private const val TAG = "LnurlCacheRepository" private const val TAG = "LnurlCacheRepository"
data class LnurlCacheEntry(
val key : String,
val response : LnurlScanResponse,
val savedAt : Long
)
class LnurlCacheRepository(context: Context) { class LnurlCacheRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences( private val dao = AppDatabase.getInstance(context).lnurlCacheDao()
WalletConstants.LNURL_CACHE_PREFS_NAME, Context.MODE_PRIVATE private val gson = GsonProvider.gson
)
private val gson = Gson()
// In-memory map for fast access within a session // Fast in-memory path — avoids a DB round-trip for keys already loaded
private val memCache = mutableMapOf<String, LnurlCacheEntry>() // 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: * Returns a cached [LnurlScanResponse] for [key], or null if:
* - no entry exists * - no entry exists
* - the entry is older than [WalletConstants.LNURL_CACHE_TTL_MS] * - 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? { suspend fun get(key: String): LnurlScanResponse? {
val entry = memCache[key] ?: return null val entity = memCache[key] ?: dao.getByKey(key)?.also { memCache[key] = it }
val ageMs = System.currentTimeMillis() - entry.savedAt ?: return null
val ageMs = System.currentTimeMillis() - entity.savedAt
if (ageMs > WalletConstants.LNURL_CACHE_TTL_MS) { if (ageMs > WalletConstants.LNURL_CACHE_TTL_MS) {
Log.d(TAG, "LNURL [CACHE STALE] $key (age ${ageMs / 1000}s)") Log.d(TAG, "LNURL [CACHE STALE] $key (age ${ageMs / 1000}s)")
memCache.remove(key) evict(key)
persistToDisk() return null
}
val response = deserialise(entity.responseJson) ?: run {
Log.w(TAG, "LNURL [DESER ERR ] $key — removing corrupt entry")
evict(key)
return null return null
} }
// LUD-06: disposable == null means treat as true — do not serve from cache // LUD-06: disposable == null means treat as true — do not serve from cache
val disposable = entry.response.disposable val disposable = response.disposable
if (disposable == null || disposable == true) { if (disposable == true) {
Log.d(TAG, "LNURL [DISPOSABLE ] $key — not serving from cache (disposable=$disposable)") Log.d(TAG, "LNURL [DISPOSABLE ] $key — not serving from cache (disposable=$disposable)")
return null return null
} }
Log.d(TAG, "LNURL [CACHE HIT ] $key (age ${ageMs / 1000}s)") Log.d(TAG, "LNURL [CACHE HIT ] $key (age ${ageMs / 1000}s)")
return entry.response return response
} }
/** /**
* Stores [response] under [key]. * Stores [response] under [key].
* Silently skips if the response is marked disposable (or disposable is null). * 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 val disposable = response.disposable
if (disposable == null || disposable == true) { if ( disposable == true) {
Log.d(TAG, "LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching") Log.d(TAG, "LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching")
return return
} }
val entry = LnurlCacheEntry(key, response, System.currentTimeMillis()) val entity = LnurlCacheEntity(
memCache[key] = entry key = key,
persistToDisk() responseJson = gson.toJson(response),
Log.d(TAG, "LNURL [CACHE SET ] $key") savedAt = System.currentTimeMillis()
} )
/**
* 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 { runCatching {
val type = object : TypeToken<List<LnurlCacheEntry>>() {}.type dao.insert(entity)
val list: List<LnurlCacheEntry> = gson.fromJson(json, type) memCache[key] = entity
val now = System.currentTimeMillis() Log.d(TAG, "LNURL [CACHE SET ] $key")
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 { }.onFailure {
Log.w(TAG, "LNURL [PERSIST ERR] ${it.message}") 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.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database( @Database(
entities = [PaymentRecordEntity::class], entities = [PaymentRecordEntity::class, LnurlCacheEntity::class],
version = 1, version = 2,
exportSchema = false exportSchema = false
) )
@TypeConverters(PaymentConverters::class) @TypeConverters(PaymentConverters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun paymentDao(): PaymentDao abstract fun paymentDao(): PaymentDao
abstract fun lnurlCacheDao(): LnurlCacheDao
companion object { companion object {
@Volatile private var INSTANCE: AppDatabase? = null @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 = fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
context.applicationContext, context.applicationContext,
AppDatabase::class.java, AppDatabase::class.java,
"gudari_wallet.db" "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( return WalletViewModel(
repo, repo,
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
lnurlCache = lnurlCache,
paymentCache = paymentCache, paymentCache = paymentCache,
balancePrefs = balancePrefs, balancePrefs = balancePrefs,
balanceVm = balanceVm, balanceVm = balanceVm,
@@ -36,10 +36,9 @@ object WalletConstants {
const val ALIAS_PREFS_NAME = "alias_cache_prefs" const val ALIAS_PREFS_NAME = "alias_cache_prefs"
const val ALIAS_PREFS_KEY = "alias_cache" 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 = 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 ──────────────────────────────────────────────────────── // ── 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 const val PAYMENTS_CACHE_TTL_MS = 300_000L // 5 minutes — show stale, refresh in background
// ── Payments sync ───────────────────────────────────────────────────────────── // ── Payments sync ─────────────────────────────────────────────────────────────
const val PAYMENTS_SYNC_CHECKPOINT = 100 // max payments fetched on auto-sync at startup 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_KEY = "last_balance_sats"
const val BALANCE_PREFS_SAVED_AT = "last_balance_saved_at" const val BALANCE_PREFS_SAVED_AT = "last_balance_saved_at"
const val BALANCE_CACHE_TTL_MS = 3_600_000L // 1 hour 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
} }