feat: migrate lnurlcache to room
This commit is contained in:
@@ -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),
|
||||||
|
savedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
runCatching {
|
||||||
|
dao.insert(entity)
|
||||||
|
memCache[key] = entity
|
||||||
Log.d(TAG, "LNURL [CACHE SET ] $key")
|
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 {
|
}.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
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user