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