From cad54c69e7859238578b7ecf8461500665caf32a Mon Sep 17 00:00:00 2001 From: rasputin Date: Fri, 12 Jun 2026 11:29:45 +0200 Subject: [PATCH] feat: nwc caching --- .../gudariwallet/data/NwcCacheRepository.kt | 137 ++++++++++++++++++ .../gudariwallet/data/db/AppDatabase.kt | 49 ++++++- .../gudariwallet/data/db/NwcBudgetEntity.kt | 33 +++++ .../gudariwallet/data/db/NwcKeyDao.kt | 97 +++++++++++++ .../gudariwallet/data/db/NwcKeyEntity.kt | 26 ++++ .../gudariwallet/ui/WalletScreen.kt | 2 +- .../gudariwallet/ui/WalletViewModel.kt | 2 + .../gudariwallet/ui/WalletViewModelFactory.kt | 4 + .../gudariwallet/ui/nwc/NwcScreen.kt | 3 +- .../gudariwallet/ui/nwc/NwcViewModel.kt | 136 +++++++++++++---- 10 files changed, 458 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/data/NwcCacheRepository.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/data/db/NwcBudgetEntity.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/data/db/NwcKeyDao.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/data/db/NwcKeyEntity.kt diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/NwcCacheRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/NwcCacheRepository.kt new file mode 100644 index 0000000..ce0b7a6 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/NwcCacheRepository.kt @@ -0,0 +1,137 @@ +package com.bitcointxoko.gudariwallet.data + +import android.app.Application +import com.bitcointxoko.gudariwallet.api.NwcBudget +import com.bitcointxoko.gudariwallet.api.NwcGetResponse +import com.bitcointxoko.gudariwallet.api.NwcKey +import com.bitcointxoko.gudariwallet.data.db.AppDatabase +import com.bitcointxoko.gudariwallet.data.db.NwcBudgetEntity +import com.bitcointxoko.gudariwallet.data.db.NwcKeyDao +import com.bitcointxoko.gudariwallet.data.db.NwcKeyEntity +import kotlinx.coroutines.flow.Flow + +class NwcCacheRepository(app: Application) { + private val dao: NwcKeyDao = AppDatabase.getInstance(app).nwcKeyDao() + + // ── Observe (for ViewModels to collect) ─────────────────────────── + + fun observeKeys(): Flow> = dao.observeAll() + + fun observeKey(pubkey: String): Flow = dao.observeKey(pubkey) + + fun observeBudgets(pubkey: String): Flow> = + dao.observeBudgets(pubkey) + + // ── Sync (called after network fetches) ─────────────────────────── + // One-shot synchronous read — for startup population only + suspend fun getKeysOnce(): List = + dao.getAllKeys().map { it.toDomain().toGetResponse() } + + /** + * Smart sync for the full key list. + * + * - New keys (not yet cached) → full insert + * - Existing keys → update last_used only (all other fields are immutable) + * - Keys present in cache but absent from server response → evict + * + * Never overwrites immutable fields on existing rows. + */ + suspend fun syncKeys(responses: List) { + val incoming = responses.associateBy { it.data.pubkey } + val cached = dao.getAllPubkeys().toSet() + + dao.syncTransaction { + val newPubkeys = incoming.keys - cached + newPubkeys.forEach { pubkey -> + val response = incoming.getValue(pubkey) + dao.insertKey(response.data.toEntity()) + dao.insertBudgets(response.budgets.map { it.toEntity() }) + } + + val existingPubkeys = incoming.keys intersect cached + existingPubkeys.forEach { pubkey -> + dao.updateLastUsed(pubkey, incoming.getValue(pubkey).data.last_used) + } + + val removedPubkeys = cached - incoming.keys + if (removedPubkeys.isNotEmpty()) { + dao.deleteKeys(removedPubkeys) + } + } + } + + /** + * Write a single key response into the cache. + * Used after registerNwcKey() — inserts the new key exactly once. + * IGNORE conflict strategy on the DAO ensures this is a no-op if + * somehow called twice for the same pubkey. + */ + suspend fun saveKey(response: NwcGetResponse) { + dao.insertKey(response.data.toEntity()) + dao.insertBudgets(response.budgets.map { it.toEntity() }) + } + + /** + * Update only last_used for a single key. + * Called after getNwcKey(refreshLastUsed = true) on the detail screen. + */ + suspend fun refreshLastUsed(pubkey: String, lastUsed: Long) { + dao.updateLastUsed(pubkey, lastUsed) + } + + /** + * Evict a key from the cache. + * Called after a successful deleteNwcKey() network call. + * CASCADE on the FK removes associated budgets automatically. + */ + suspend fun deleteKey(pubkey: String) { + dao.deleteKey(pubkey) + } +} + +// ── Mappers ─────────────────────────────────────────────────────────── +// Kept in this file — they exist solely to serve NwcCacheRepository. +// If mappers grow (e.g. reverse domain→entity direction is needed), +// extract to NwcEntityMappers.kt. + +private fun NwcKey.toEntity(): NwcKeyEntity = NwcKeyEntity( + pubkey = pubkey, + wallet = wallet, + description = description, + expiresAt = expires_at, + permissions = permissions, + createdAt = created_at, + lastUsed = last_used, + cachedAt = System.currentTimeMillis() / 1000L +) + +private fun NwcBudget.toEntity(): NwcBudgetEntity = NwcBudgetEntity( + id = id, + pubkey = pubkey, + budgetMsats = budget_msats, + refreshWindow = refresh_window, + createdAt = created_at, + usedBudgetMsats = used_budget_msats +) + +fun NwcKeyEntity.toDomain(): NwcKey = NwcKey( + pubkey = pubkey, + wallet = wallet, + description = description, + expires_at = expiresAt, + permissions = permissions, + created_at = createdAt, + last_used = lastUsed +) + +fun NwcBudgetEntity.toDomain(): NwcBudget = NwcBudget( + id = id, + pubkey = pubkey, + budget_msats = budgetMsats, + refresh_window = refreshWindow, + created_at = createdAt, + used_budget_msats = usedBudgetMsats +) + +fun NwcKey.toGetResponse() = NwcGetResponse(data = this, budgets = emptyList()) + 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 31e0f3b..e952d60 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 @@ -13,8 +13,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase PaymentRecordEntity::class, LnurlCacheEntity::class, NodeAliasCacheEntity::class, + NwcKeyEntity::class, + NwcBudgetEntity::class ], - version = 6, + version = 7, exportSchema = false ) @TypeConverters(PaymentConverters::class) @@ -23,6 +25,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun paymentDao(): PaymentDao abstract fun lnurlCacheDao(): LnurlCacheDao abstract fun nodeAliasCacheDao(): NodeAliasCacheDao + abstract fun nwcKeyDao(): NwcKeyDao companion object { @Volatile private var INSTANCE: AppDatabase? = null @@ -86,6 +89,41 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL(""" + CREATE TABLE IF NOT EXISTS nwc_keys ( + pubkey TEXT NOT NULL PRIMARY KEY, + wallet TEXT NOT NULL, + description TEXT NOT NULL, + expires_at INTEGER NOT NULL, + permissions TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used INTEGER NOT NULL, + cached_at INTEGER NOT NULL + ) + """.trimIndent()) + + db.execSQL(""" + CREATE TABLE IF NOT EXISTS nwc_budgets ( + id INTEGER NOT NULL PRIMARY KEY, + pubkey TEXT NOT NULL, + budget_msats INTEGER NOT NULL, + refresh_window INTEGER NOT NULL, + created_at INTEGER NOT NULL, + used_budget_msats INTEGER NOT NULL, + FOREIGN KEY(pubkey) REFERENCES nwc_keys(pubkey) + ON DELETE CASCADE + ) + """.trimIndent()) + + db.execSQL(""" + CREATE INDEX IF NOT EXISTS index_nwc_budgets_pubkey + ON nwc_budgets(pubkey) + """.trimIndent()) + } + } + fun getInstance(context: Context): AppDatabase = INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( @@ -93,7 +131,14 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "gudari_wallet.db" ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7 + ) .build() .also { INSTANCE = it } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/NwcBudgetEntity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/NwcBudgetEntity.kt new file mode 100644 index 0000000..71f2fe0 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/NwcBudgetEntity.kt @@ -0,0 +1,33 @@ +package com.bitcointxoko.gudariwallet.data.db + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "nwc_budgets", + foreignKeys = [ForeignKey( + entity = NwcKeyEntity::class, + parentColumns = ["pubkey"], + childColumns = ["pubkey"], + onDelete = ForeignKey.CASCADE // auto-cleans budgets when key is deleted + )], + indices = [Index("pubkey")] +) +data class NwcBudgetEntity( + @PrimaryKey + @ColumnInfo(name = "id") + val id: Int, + @ColumnInfo(name = "pubkey") + val pubkey: String, + @ColumnInfo(name = "budget_msats") + val budgetMsats: Long, + @ColumnInfo(name = "refresh_window") + val refreshWindow: Int, + @ColumnInfo(name = "created_at") + val createdAt: Long, + @ColumnInfo(name = "used_budget_msats") + val usedBudgetMsats: Long +) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/NwcKeyDao.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/NwcKeyDao.kt new file mode 100644 index 0000000..255c7d0 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/NwcKeyDao.kt @@ -0,0 +1,97 @@ +package com.bitcointxoko.gudariwallet.data.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +@Dao +interface NwcKeyDao { + + // ── Observe (UI layer subscriptions) ────────────────────────────── + + /** + * Live stream of all cached keys, newest first. + * Room re-emits automatically on any write — this is the only + * channel the list screen needs to collect. + */ + @Query("SELECT * FROM nwc_keys ORDER BY created_at DESC") + fun observeAll(): Flow> + + /** + * Live stream of a single key — for the detail screen. + * Emits null if the key has been evicted (e.g. after a delete). + */ + @Query("SELECT * FROM nwc_keys WHERE pubkey = :pubkey") + fun observeKey(pubkey: String): Flow + + /** + * Live stream of budgets for a given key — for the detail screen. + * CASCADE delete on the parent ensures this automatically becomes + * empty when the parent key is deleted. + */ + @Query("SELECT * FROM nwc_budgets WHERE pubkey = :pubkey") + fun observeBudgets(pubkey: String): Flow> + + // ── One-shot reads (sync logic, not UI) ─────────────────────────── + + /** + * Lightweight membership check used by syncKeys() to determine + * which incoming keys are new vs. already cached. + * Returns only pubkeys — avoids loading full rows unnecessarily. + */ + @Query("SELECT pubkey FROM nwc_keys") + suspend fun getAllPubkeys(): List + + /** + * One-shot full read of all cached keys — used for instant startup + * population before the Flow subscription is active. + * Same query as observeAll() but returns a plain list instead of Flow. + */ + @Query("SELECT * FROM nwc_keys ORDER BY created_at DESC") + suspend fun getAllKeys(): List + + // ── Writes ──────────────────────────────────────────────────────── + + /** + * Insert a brand-new key. IGNORE on conflict means an existing row + * is never overwritten by this path — all immutable fields are safe. + * Used exclusively when a key is first seen (creation or first sync). + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertKey(key: NwcKeyEntity) + + @Transaction + suspend fun syncTransaction(block: suspend () -> Unit) = block() + + /** + * Insert budgets for a new key. Also IGNORE on conflict — + * budget fields are immutable post-creation. + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertBudgets(budgets: List) + + /** + * Update ONLY last_used — the single mutable field on a key. + * This is the only update path that should ever run post-creation. + */ + @Query("UPDATE nwc_keys SET last_used = :lastUsed WHERE pubkey = :pubkey") + suspend fun updateLastUsed(pubkey: String, lastUsed: Long) + + /** + * Evict a key by pubkey. The CASCADE foreign key on nwc_budgets + * automatically deletes all associated budget rows — no manual + * budget cleanup needed. + */ + @Query("DELETE FROM nwc_keys WHERE pubkey = :pubkey") + suspend fun deleteKey(pubkey: String) + + /** + * Evict a set of keys in one statement — used by syncKeys() to + * remove server-deleted keys without issuing N individual deletes. + */ + @Query("DELETE FROM nwc_keys WHERE pubkey IN (:pubkeys)") + suspend fun deleteKeys(pubkeys: Collection) +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/NwcKeyEntity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/NwcKeyEntity.kt new file mode 100644 index 0000000..f006361 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/NwcKeyEntity.kt @@ -0,0 +1,26 @@ +package com.bitcointxoko.gudariwallet.data.db + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "nwc_keys") +data class NwcKeyEntity( + @PrimaryKey + @ColumnInfo(name = "pubkey") + val pubkey: String, + @ColumnInfo(name = "wallet") + val wallet: String, + @ColumnInfo(name = "description") + val description: String, + @ColumnInfo(name = "expires_at") + val expiresAt: Long, + @ColumnInfo(name = "permissions") + val permissions: String, // stored as comma-separated string + @ColumnInfo(name = "created_at") + val createdAt: Long, + @ColumnInfo(name = "last_used") + val lastUsed: Long, + @ColumnInfo(name = "cached_at") + val cachedAt: Long // epoch-seconds, used for staleness check if desired +) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt index 4131cf2..7bd3ade 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt @@ -127,7 +127,7 @@ fun WalletScreen( } val historyVm: HistoryViewModel = viewModel(factory = historyFactory) val nwcVm: NwcViewModel = viewModel( - factory = NwcViewModel.Factory(repo = vm.repo) // same pattern as historyVm + factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm ) val navBackStackEntry by navController.currentBackStackEntryAsState() diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt index 09278de..dd4e006 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.LightningAddressStore +import com.bitcointxoko.gudariwallet.data.NwcCacheRepository import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel @@ -28,6 +29,7 @@ class WalletViewModel( val repo : WalletRepository, val aliasRepo : NodeAliasRepository, val paymentCache: PaymentCacheRepository, + val nwcCache : NwcCacheRepository, private val balancePrefs: BalancePrefsStore, private val lnAddressStore : LightningAddressStore, val balanceVm : BalanceViewModel, 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 53556d8..19aec21 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -11,7 +11,9 @@ import com.bitcointxoko.gudariwallet.data.NodeAliasServiceRepository import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository import com.bitcointxoko.gudariwallet.data.LightningAddressStore import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository +import com.bitcointxoko.gudariwallet.data.NwcCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository +import com.bitcointxoko.gudariwallet.data.db.AppDatabase import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.service.NodeAliasService import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel @@ -35,6 +37,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache) val poller = PaymentPoller(repo) val paymentCache = PaymentCacheRepository(app) + val nwcCache = NwcCacheRepository(app) val fiatDataStore = FiatDataStore(app) val balancePrefs = BalancePrefsStore(app) val lnAddressStore = LightningAddressStore(app) @@ -62,6 +65,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F repo, aliasRepo = aliasRepo, paymentCache = paymentCache, + nwcCache = nwcCache, balancePrefs = balancePrefs, balanceVm = balanceVm, fiatVm = fiatVm, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt index f491277..e1acbbe 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt @@ -58,7 +58,8 @@ fun NwcScreen( snackbarHost = { SnackbarHost(snackbarHostState) } ) { innerPadding -> - val isRefreshing = uiState is NwcUiState.Loading + val isRefreshing = uiState is NwcUiState.Loading || + (uiState as? NwcUiState.Success)?.isRefreshingLastUsed == true PullToRefreshBox( isRefreshing = isRefreshing, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt index b64b010..a98f4f0 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt @@ -6,12 +6,21 @@ import androidx.lifecycle.viewModelScope import com.bitcointxoko.gudariwallet.api.NwcGetResponse import com.bitcointxoko.gudariwallet.api.NwcNewBudget import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest +import com.bitcointxoko.gudariwallet.data.NwcCacheRepository import com.bitcointxoko.gudariwallet.data.WalletRepository +import com.bitcointxoko.gudariwallet.data.db.toDomain +import com.bitcointxoko.gudariwallet.data.toDomain +import com.bitcointxoko.gudariwallet.data.toGetResponse import com.bitcointxoko.gudariwallet.util.NwcKeyGenerator import com.bitcointxoko.gudariwallet.util.NwcKeypair +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber @@ -21,7 +30,10 @@ private const val TAG = "NwcViewModel" sealed interface NwcUiState { data object Loading : NwcUiState - data class Success(val connections: List) : NwcUiState + data class Success( + val connections: List, + val isRefreshingLastUsed: Boolean = false // true only while network sync is in-flight + ) : NwcUiState data class Error(val message: String) : NwcUiState } @@ -37,7 +49,8 @@ sealed interface ConnectionDetailState { // ── ViewModel ───────────────────────────────────────────────────────────────── class NwcViewModel( - private val repo: WalletRepository + private val repo: WalletRepository, + private val cache: NwcCacheRepository ) : ViewModel() { private val _uiState = MutableStateFlow(NwcUiState.Loading) @@ -64,8 +77,11 @@ class NwcViewModel( MutableStateFlow(ConnectionDetailState.Loading) val connectionDetailState: StateFlow = _connectionDetailState.asStateFlow() + private var detailObserverJob: Job? = null + private var cacheObserverJob: Job? = null // ── Sheet / dialog UI state (survives config change) ──────────────────────── + private var isSyncing = false private val _showAddSheet = MutableStateFlow(false) val showAddSheet: StateFlow = _showAddSheet.asStateFlow() @@ -85,25 +101,64 @@ class NwcViewModel( } init { - loadConnections() + loadConnections(includeExpired = true) } // ── Load ────────────────────────────────────────────────────────────────── fun loadConnections(includeExpired: Boolean = false) { Timber.d("[$TAG] loadConnections(includeExpired=$includeExpired)") - _uiState.value = NwcUiState.Loading + isSyncing = true + + // 1. Subscribe to cache — emits immediately if rows exist, stays alive + // for the ViewModel lifetime. Every cache write re-emits automatically. + if (cacheObserverJob == null) { + // Fast path: one-shot read populates UI before the Flow cold-starts + viewModelScope.launch { + val snapshot = cache.getKeysOnce() + if (snapshot.isNotEmpty() && _uiState.value !is NwcUiState.Success) { + Timber.d("[$TAG] cache snapshot: ${snapshot.size} connection(s)") + _uiState.value = NwcUiState.Success( + connections = snapshot, + isRefreshingLastUsed = true + ) + } + } + cacheObserverJob = cache.observeKeys() + .drop(1) // skip first emission — snapshot already handled it + .onEach { cached -> + if (cached.isNotEmpty()) { + Timber.d("[$TAG] cache hit: ${cached.size} connection(s)") + _uiState.value = NwcUiState.Success( + connections = cached.map { it.toDomain().toGetResponse() }, + isRefreshingLastUsed = isSyncing // ← reflects current sync state, not always true + ) + } else if (_uiState.value !is NwcUiState.Success) { + _uiState.value = NwcUiState.Loading + } + } + .launchIn(viewModelScope) + } + + // 2. Fetch from network; result is written to cache which re-emits above viewModelScope.launch { runCatching { repo.getNwcKeys(includeExpired) } .onSuccess { keys -> - Timber.i("[$TAG] loaded ${keys.size} NWC connection(s)") - _uiState.value = NwcUiState.Success(keys) + Timber.i("[$TAG] network: ${keys.size} NWC connection(s)") + isSyncing = false + cache.syncKeys(keys) // Room write → Flow re-emits } .onFailure { e -> Timber.e(e, "[$TAG] failed to load NWC connections") - _uiState.value = NwcUiState.Error( - e.message ?: "Failed to load connections" - ) + // Only show error if we have nothing cached to show + isSyncing = false + if (_uiState.value !is NwcUiState.Success) { + _uiState.value = NwcUiState.Error( + e.message ?: "Failed to load connections" + ) + } else { + Timber.w("[$TAG] network failed but cache is showing — non-fatal") + } } } } @@ -135,7 +190,7 @@ class NwcViewModel( Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}… (${keypair.pubKeyHex.length} chars)") Timber.d("[$TAG] REGISTERING pubkey=${keypair.pubKeyHex}") - repo.registerNwcKey( + val registered = repo.registerNwcKey( pubkey = keypair.pubKeyHex, request = NwcRegistrationRequest( description = description, @@ -145,7 +200,8 @@ class NwcViewModel( ) ) Timber.i("[$TAG] registered key ✓") - + cache.saveKey(registered) + Timber.d("[$TAG] new key cached ✓") val url = repo.getNwcPairingUrl(keypair.privKeyHex) Timber.i("[$TAG] pairing URL fetched ✓") Timber.d("[$TAG] URI=$url") @@ -186,6 +242,7 @@ class NwcViewModel( runCatching { repo.deleteNwcKey(pubkey) } .onSuccess { Timber.i("[$TAG] deleted connection pubkey=${pubkey.take(16)}… ✓") + cache.deleteKey(pubkey) val current = _uiState.value if (current is NwcUiState.Success) { _uiState.value = NwcUiState.Success( @@ -209,27 +266,48 @@ class NwcViewModel( fun loadConnectionDetail(pubkey: String) { Timber.d("[$TAG] loadConnectionDetail pubkey=${pubkey.take(16)}…") + + // Cancel any subscription from a previous key — prevents stale Flow + // from a previously viewed key overwriting the current detail state + detailObserverJob?.cancel() _connectionDetailState.value = ConnectionDetailState.Loading + + detailObserverJob = combine( + cache.observeKey(pubkey), + cache.observeBudgets(pubkey) + ) { keyEntity, budgetEntities -> + Pair(keyEntity, budgetEntities) + } + .onEach { (keyEntity, budgetEntities) -> + if (keyEntity != null) { + Timber.d("[$TAG] detail cache hit for ${pubkey.take(16)}…") + _connectionDetailState.value = ConnectionDetailState.Success( + connection = keyEntity.toDomain().toConnectionData(), + budgets = budgetEntities.map { it.toDomain().toBudgetData() } + ) + } else if (_connectionDetailState.value !is ConnectionDetailState.Success) { + _connectionDetailState.value = ConnectionDetailState.Loading + } + } + .launchIn(viewModelScope) // ← now tracked and cancellable + viewModelScope.launch { runCatching { - repo.getNwcKey( - pubkey = pubkey, - includeExpired = true, // show detail even for expired keys - refreshLastUsed = false - ) + repo.getNwcKey(pubkey = pubkey, includeExpired = true, refreshLastUsed = false) } .onSuccess { response -> Timber.i("[$TAG] loaded connection detail ✓") - _connectionDetailState.value = ConnectionDetailState.Success( - connection = response.data.toConnectionData(), - budgets = response.budgets.map { it.toBudgetData() } - ) + cache.refreshLastUsed(pubkey, response.data.last_used) } .onFailure { e -> Timber.e(e, "[$TAG] failed to load connection detail") - _connectionDetailState.value = ConnectionDetailState.Error( - e.message ?: "Failed to load connection" - ) + if (_connectionDetailState.value !is ConnectionDetailState.Success) { + _connectionDetailState.value = ConnectionDetailState.Error( + e.message ?: "Failed to load connection" + ) + } else { + Timber.w("[$TAG] detail network refresh failed — cache showing (non-fatal)") + } } } } @@ -237,6 +315,8 @@ class NwcViewModel( /** Reset to Loading so the next open always shows a fresh load. */ fun clearConnectionDetail() { Timber.d("[$TAG] clearConnectionDetail") + detailObserverJob?.cancel() // ← stop the old key's Flow immediately + detailObserverJob = null _connectionDetailState.value = ConnectionDetailState.Loading } @@ -253,7 +333,7 @@ class NwcViewModel( runCatching { repo.getNwcKeys(includeExpired) } .onSuccess { keys -> Timber.d("[$TAG] silentRefresh: ${keys.size} connection(s)") - _uiState.value = NwcUiState.Success(keys) + cache.syncKeys(keys) // Room write → cache Flow re-emits to UI } .onFailure { e -> Timber.w(e, "[$TAG] silentRefresh failed (non-fatal)") @@ -264,10 +344,12 @@ class NwcViewModel( // ── Factory ─────────────────────────────────────────────────────────────── - class Factory(private val repo: WalletRepository) : - ViewModelProvider.Factory { + class Factory( + private val repo: WalletRepository, + private val cache: NwcCacheRepository + ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = - NwcViewModel(repo) as T + NwcViewModel(repo, cache) as T } }