diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LnurlCacheRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LnurlCacheRepository.kt index 43cb44a..7e942b6 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LnurlCacheRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LnurlCacheRepository.kt @@ -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() + // 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() - 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() - 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 + val entity = LnurlCacheEntity( + key = key, + responseJson = gson.toJson(response), + savedAt = System.currentTimeMillis() + ) runCatching { - val type = object : TypeToken>() {}.type - val list: List = 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() + dao.insert(entity) + memCache[key] = entity + Log.d(TAG, "LNURL [CACHE SET ] $key") }.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() } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt index ac060c1..ed86a07 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt @@ -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 } } } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/LnurlCacheDao.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/LnurlCacheDao.kt new file mode 100644 index 0000000..46cfe03 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/LnurlCacheDao.kt @@ -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() +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/LnurlCacheEntity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/LnurlCacheEntity.kt new file mode 100644 index 0000000..4ece9cc --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/LnurlCacheEntity.kt @@ -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 +) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt index cce4fa8..8d8d592 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -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, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt index bd5d618..efde154 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt @@ -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 + } \ No newline at end of file