feat: nwc caching
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user