feat: nwc caching

This commit is contained in:
2026-06-12 11:29:45 +02:00
parent 25d13b5a0b
commit cad54c69e7
10 changed files with 458 additions and 31 deletions
@@ -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<List<NwcKeyEntity>> = dao.observeAll()
fun observeKey(pubkey: String): Flow<NwcKeyEntity?> = dao.observeKey(pubkey)
fun observeBudgets(pubkey: String): Flow<List<NwcBudgetEntity>> =
dao.observeBudgets(pubkey)
// ── Sync (called after network fetches) ───────────────────────────
// One-shot synchronous read — for startup population only
suspend fun getKeysOnce(): List<NwcGetResponse> =
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<NwcGetResponse>) {
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())
@@ -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 }
}
@@ -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
)
@@ -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<List<NwcKeyEntity>>
/**
* 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<NwcKeyEntity?>
/**
* 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<List<NwcBudgetEntity>>
// ── 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<String>
/**
* 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<NwcKeyEntity>
// ── 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<NwcBudgetEntity>)
/**
* 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<String>)
}
@@ -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
)
@@ -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()
@@ -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,
@@ -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,
@@ -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,
@@ -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<NwcGetResponse>) : NwcUiState
data class Success(
val connections: List<NwcGetResponse>,
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>(NwcUiState.Loading)
@@ -64,8 +77,11 @@ class NwcViewModel(
MutableStateFlow<ConnectionDetailState>(ConnectionDetailState.Loading)
val connectionDetailState: StateFlow<ConnectionDetailState> =
_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<Boolean> = _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 <T : ViewModel> create(modelClass: Class<T>): T =
NwcViewModel(repo) as T
NwcViewModel(repo, cache) as T
}
}