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,
|
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)")
|
||||||
|
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
|
_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")
|
||||||
|
// Only show error if we have nothing cached to show
|
||||||
|
isSyncing = false
|
||||||
|
if (_uiState.value !is NwcUiState.Success) {
|
||||||
_uiState.value = NwcUiState.Error(
|
_uiState.value = NwcUiState.Error(
|
||||||
e.message ?: "Failed to load connections"
|
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")
|
||||||
|
if (_connectionDetailState.value !is ConnectionDetailState.Success) {
|
||||||
_connectionDetailState.value = ConnectionDetailState.Error(
|
_connectionDetailState.value = ConnectionDetailState.Error(
|
||||||
e.message ?: "Failed to load connection"
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user