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, PaymentRecordEntity::class,
LnurlCacheEntity::class, LnurlCacheEntity::class,
NodeAliasCacheEntity::class, NodeAliasCacheEntity::class,
NwcKeyEntity::class,
NwcBudgetEntity::class
], ],
version = 6, version = 7,
exportSchema = false exportSchema = false
) )
@TypeConverters(PaymentConverters::class) @TypeConverters(PaymentConverters::class)
@@ -23,6 +25,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun paymentDao(): PaymentDao abstract fun paymentDao(): PaymentDao
abstract fun lnurlCacheDao(): LnurlCacheDao abstract fun lnurlCacheDao(): LnurlCacheDao
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
abstract fun nwcKeyDao(): NwcKeyDao
companion object { companion object {
@Volatile private var INSTANCE: AppDatabase? = null @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 = fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
@@ -93,7 +131,14 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"gudari_wallet.db" "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() .build()
.also { INSTANCE = it } .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 historyVm: HistoryViewModel = viewModel(factory = historyFactory)
val nwcVm: NwcViewModel = viewModel( 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() val navBackStackEntry by navController.currentBackStackEntryAsState()
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.LightningAddressStore import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
@@ -28,6 +29,7 @@ class WalletViewModel(
val repo : WalletRepository, val repo : WalletRepository,
val aliasRepo : NodeAliasRepository, val aliasRepo : NodeAliasRepository,
val paymentCache: PaymentCacheRepository, val paymentCache: PaymentCacheRepository,
val nwcCache : NwcCacheRepository,
private val balancePrefs: BalancePrefsStore, private val balancePrefs: BalancePrefsStore,
private val lnAddressStore : LightningAddressStore, private val lnAddressStore : LightningAddressStore,
val balanceVm : BalanceViewModel, val balanceVm : BalanceViewModel,
@@ -11,7 +11,9 @@ import com.bitcointxoko.gudariwallet.data.NodeAliasServiceRepository
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
import com.bitcointxoko.gudariwallet.data.LightningAddressStore import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.NodeAliasService import com.bitcointxoko.gudariwallet.service.NodeAliasService
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel 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 lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)
val poller = PaymentPoller(repo) val poller = PaymentPoller(repo)
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
val nwcCache = NwcCacheRepository(app)
val fiatDataStore = FiatDataStore(app) val fiatDataStore = FiatDataStore(app)
val balancePrefs = BalancePrefsStore(app) val balancePrefs = BalancePrefsStore(app)
val lnAddressStore = LightningAddressStore(app) val lnAddressStore = LightningAddressStore(app)
@@ -62,6 +65,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
repo, repo,
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
nwcCache = nwcCache,
balancePrefs = balancePrefs, balancePrefs = balancePrefs,
balanceVm = balanceVm, balanceVm = balanceVm,
fiatVm = fiatVm, fiatVm = fiatVm,
@@ -58,7 +58,8 @@ fun NwcScreen(
snackbarHost = { SnackbarHost(snackbarHostState) } snackbarHost = { SnackbarHost(snackbarHostState) }
) { innerPadding -> ) { innerPadding ->
val isRefreshing = uiState is NwcUiState.Loading val isRefreshing = uiState is NwcUiState.Loading ||
(uiState as? NwcUiState.Success)?.isRefreshingLastUsed == true
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
@@ -6,12 +6,21 @@ import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.NwcGetResponse import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.api.NwcNewBudget import com.bitcointxoko.gudariwallet.api.NwcNewBudget
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository 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.NwcKeyGenerator
import com.bitcointxoko.gudariwallet.util.NwcKeypair import com.bitcointxoko.gudariwallet.util.NwcKeypair
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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 kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -21,7 +30,10 @@ private const val TAG = "NwcViewModel"
sealed interface NwcUiState { sealed interface NwcUiState {
data object Loading : 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 data class Error(val message: String) : NwcUiState
} }
@@ -37,7 +49,8 @@ sealed interface ConnectionDetailState {
// ── ViewModel ───────────────────────────────────────────────────────────────── // ── ViewModel ─────────────────────────────────────────────────────────────────
class NwcViewModel( class NwcViewModel(
private val repo: WalletRepository private val repo: WalletRepository,
private val cache: NwcCacheRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow<NwcUiState>(NwcUiState.Loading) private val _uiState = MutableStateFlow<NwcUiState>(NwcUiState.Loading)
@@ -64,8 +77,11 @@ class NwcViewModel(
MutableStateFlow<ConnectionDetailState>(ConnectionDetailState.Loading) MutableStateFlow<ConnectionDetailState>(ConnectionDetailState.Loading)
val connectionDetailState: StateFlow<ConnectionDetailState> = val connectionDetailState: StateFlow<ConnectionDetailState> =
_connectionDetailState.asStateFlow() _connectionDetailState.asStateFlow()
private var detailObserverJob: Job? = null
private var cacheObserverJob: Job? = null
// ── Sheet / dialog UI state (survives config change) ──────────────────────── // ── Sheet / dialog UI state (survives config change) ────────────────────────
private var isSyncing = false
private val _showAddSheet = MutableStateFlow(false) private val _showAddSheet = MutableStateFlow(false)
val showAddSheet: StateFlow<Boolean> = _showAddSheet.asStateFlow() val showAddSheet: StateFlow<Boolean> = _showAddSheet.asStateFlow()
@@ -85,25 +101,64 @@ class NwcViewModel(
} }
init { init {
loadConnections() loadConnections(includeExpired = true)
} }
// ── Load ────────────────────────────────────────────────────────────────── // ── Load ──────────────────────────────────────────────────────────────────
fun loadConnections(includeExpired: Boolean = false) { fun loadConnections(includeExpired: Boolean = false) {
Timber.d("[$TAG] loadConnections(includeExpired=$includeExpired)") 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 { viewModelScope.launch {
runCatching { repo.getNwcKeys(includeExpired) } runCatching { repo.getNwcKeys(includeExpired) }
.onSuccess { keys -> .onSuccess { keys ->
Timber.i("[$TAG] loaded ${keys.size} NWC connection(s)") Timber.i("[$TAG] network: ${keys.size} NWC connection(s)")
_uiState.value = NwcUiState.Success(keys) isSyncing = false
cache.syncKeys(keys) // Room write → Flow re-emits
} }
.onFailure { e -> .onFailure { e ->
Timber.e(e, "[$TAG] failed to load NWC connections") Timber.e(e, "[$TAG] failed to load NWC connections")
_uiState.value = NwcUiState.Error( // Only show error if we have nothing cached to show
e.message ?: "Failed to load connections" 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] generated keypair pubkey=${keypair.pubKeyHex.take(16)}… (${keypair.pubKeyHex.length} chars)")
Timber.d("[$TAG] REGISTERING pubkey=${keypair.pubKeyHex}") Timber.d("[$TAG] REGISTERING pubkey=${keypair.pubKeyHex}")
repo.registerNwcKey( val registered = repo.registerNwcKey(
pubkey = keypair.pubKeyHex, pubkey = keypair.pubKeyHex,
request = NwcRegistrationRequest( request = NwcRegistrationRequest(
description = description, description = description,
@@ -145,7 +200,8 @@ class NwcViewModel(
) )
) )
Timber.i("[$TAG] registered key ✓") Timber.i("[$TAG] registered key ✓")
cache.saveKey(registered)
Timber.d("[$TAG] new key cached ✓")
val url = repo.getNwcPairingUrl(keypair.privKeyHex) val url = repo.getNwcPairingUrl(keypair.privKeyHex)
Timber.i("[$TAG] pairing URL fetched ✓") Timber.i("[$TAG] pairing URL fetched ✓")
Timber.d("[$TAG] URI=$url") Timber.d("[$TAG] URI=$url")
@@ -186,6 +242,7 @@ class NwcViewModel(
runCatching { repo.deleteNwcKey(pubkey) } runCatching { repo.deleteNwcKey(pubkey) }
.onSuccess { .onSuccess {
Timber.i("[$TAG] deleted connection pubkey=${pubkey.take(16)}… ✓") Timber.i("[$TAG] deleted connection pubkey=${pubkey.take(16)}… ✓")
cache.deleteKey(pubkey)
val current = _uiState.value val current = _uiState.value
if (current is NwcUiState.Success) { if (current is NwcUiState.Success) {
_uiState.value = NwcUiState.Success( _uiState.value = NwcUiState.Success(
@@ -209,27 +266,48 @@ class NwcViewModel(
fun loadConnectionDetail(pubkey: String) { fun loadConnectionDetail(pubkey: String) {
Timber.d("[$TAG] loadConnectionDetail pubkey=${pubkey.take(16)}") 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 _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 { viewModelScope.launch {
runCatching { runCatching {
repo.getNwcKey( repo.getNwcKey(pubkey = pubkey, includeExpired = true, refreshLastUsed = false)
pubkey = pubkey,
includeExpired = true, // show detail even for expired keys
refreshLastUsed = false
)
} }
.onSuccess { response -> .onSuccess { response ->
Timber.i("[$TAG] loaded connection detail ✓") Timber.i("[$TAG] loaded connection detail ✓")
_connectionDetailState.value = ConnectionDetailState.Success( cache.refreshLastUsed(pubkey, response.data.last_used)
connection = response.data.toConnectionData(),
budgets = response.budgets.map { it.toBudgetData() }
)
} }
.onFailure { e -> .onFailure { e ->
Timber.e(e, "[$TAG] failed to load connection detail") Timber.e(e, "[$TAG] failed to load connection detail")
_connectionDetailState.value = ConnectionDetailState.Error( if (_connectionDetailState.value !is ConnectionDetailState.Success) {
e.message ?: "Failed to load connection" _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. */ /** Reset to Loading so the next open always shows a fresh load. */
fun clearConnectionDetail() { fun clearConnectionDetail() {
Timber.d("[$TAG] clearConnectionDetail") Timber.d("[$TAG] clearConnectionDetail")
detailObserverJob?.cancel() // ← stop the old key's Flow immediately
detailObserverJob = null
_connectionDetailState.value = ConnectionDetailState.Loading _connectionDetailState.value = ConnectionDetailState.Loading
} }
@@ -253,7 +333,7 @@ class NwcViewModel(
runCatching { repo.getNwcKeys(includeExpired) } runCatching { repo.getNwcKeys(includeExpired) }
.onSuccess { keys -> .onSuccess { keys ->
Timber.d("[$TAG] silentRefresh: ${keys.size} connection(s)") 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 -> .onFailure { e ->
Timber.w(e, "[$TAG] silentRefresh failed (non-fatal)") Timber.w(e, "[$TAG] silentRefresh failed (non-fatal)")
@@ -264,10 +344,12 @@ class NwcViewModel(
// ── Factory ─────────────────────────────────────────────────────────────── // ── Factory ───────────────────────────────────────────────────────────────
class Factory(private val repo: WalletRepository) : class Factory(
ViewModelProvider.Factory { private val repo: WalletRepository,
private val cache: NwcCacheRepository
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = override fun <T : ViewModel> create(modelClass: Class<T>): T =
NwcViewModel(repo) as T NwcViewModel(repo, cache) as T
} }
} }