feat: websocket events update payment db

This commit is contained in:
2026-06-01 15:44:19 +02:00
parent 18c8697ce9
commit 385536db25
6 changed files with 277 additions and 68 deletions
@@ -8,21 +8,20 @@ import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class HistoryViewModel(
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository,
val fiatCurrency : StateFlow<String?>, // passed from WalletViewModel.selectedCurrency
val fiatSatsPerUnit : StateFlow<Double?> // passed from WalletViewModel.fiatSatsPerUnit
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository,
val fiatCurrency : StateFlow<String?>,
val fiatSatsPerUnit : StateFlow<Double?>
) : ViewModel() {
private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading)
@@ -32,27 +31,126 @@ class HistoryViewModel(
private var loadJob: Job? = null
init {
// Cache read moved to IO dispatcher — never blocks the main thread,
// so navigation to HistoryScreen is instant on tap.
viewModelScope.launch {
val cached = paymentCache.loadCachedPayments()
// Serve first page from DB immediately — no TTL, always valid
val cached = paymentCache.loadCachedPage(
offset = 0,
limit = WalletConstants.PAYMENTS_PAGE_SIZE
)
if (cached.isNotEmpty()) {
Log.d("HistoryViewModel", "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately")
Log.d(TAG, "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately")
currentOffset = cached.size
_state.value = HistoryState.Success(
payments = cached,
canLoadMore = cached.size >= WalletConstants.PAYMENTS_PAGE_SIZE,
isRefreshing = true
)
} else {
Log.d("HistoryViewModel", "PAYMENTS [INIT ] No cache — cold load")
Log.d(TAG, "PAYMENTS [INIT ] No cache — cold load")
}
syncNewPayments()
}
// ── Live WebSocket updates ────────────────────────────────────────────────
// The payment is already written to the DB by WalletNotificationService
// before this event is emitted. syncNewPayments() will find it immediately
// via containsPayment() on the first page and stop after merging it.
viewModelScope.launch {
WalletNotificationService.paymentEvents.collect { event ->
Log.d(TAG, "PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list")
val current = _state.value
if (current is HistoryState.Success) {
_state.value = current.copy(isRefreshing = true)
}
syncNewPayments()
}
loadPage()
}
// Note: no loadFiatRate() here — fiatSatsPerUnit is owned by WalletViewModel
// and already kept up to date. Zero extra network calls.
}
// ── Payments list ─────────────────────────────────────────────────────────
// ── Sync: fetch pages from network until we hit a known checkingId ────────
/**
* Fetches pages from the server starting at offset 0, merging each into the
* DB, until a payment is found that already exists in the DB (meaning we've
* caught up), or the server returns a partial page (end of history).
*
* This fills any gap caused by payments that arrived while the app was
* offline, without re-fetching history we already have.
*/
private suspend fun syncNewPayments(maxPayments: Int = WalletConstants.PAYMENTS_SYNC_CHECKPOINT) {
var syncOffset = 0
var totalNew = 0
var pagesChecked = 0
Log.d(TAG, "PAYMENTS [SYNC START] scanning for new payments (cap=$maxPayments)")
while (true) {
// ── Checkpoint guard ──────────────────────────────────────────────────
if (totalNew >= maxPayments) {
Log.d(TAG, "PAYMENTS [SYNC CAP ] reached $maxPayments payment limit — stopping early")
break
}
val page = runCatching {
repo.getPayments(
offset = syncOffset,
limit = WalletConstants.PAYMENTS_PAGE_SIZE
)
}.getOrElse { e ->
val current = _state.value
if (current is HistoryState.Success) {
Log.w(TAG, "PAYMENTS [SYNC ERR ] ${e.message} — keeping cached list")
_state.value = current.copy(isRefreshing = false)
} else {
Log.w(TAG, "PAYMENTS [SYNC ERR ] ${e.message} — no cache to fall back on")
_state.value = HistoryState.Error(e.message ?: "Failed to load payments")
}
return
}
pagesChecked++
val overlapIndex = page.indexOfFirst { paymentCache.containsPayment(it.checkingId) }
val newPayments = if (overlapIndex == -1) page
else page.subList(0, overlapIndex)
// Respect the cap — don't write more than allowed
val allowedNew = maxPayments - totalNew
val toMerge = if (newPayments.size > allowedNew) newPayments.subList(0, allowedNew)
else newPayments
if (toMerge.isNotEmpty()) {
paymentCache.mergePayments(toMerge)
totalNew += toMerge.size
Log.d(TAG, "PAYMENTS [SYNC PAGE ] page $pagesChecked${toMerge.size} new payments merged")
}
val reachedOverlap = overlapIndex != -1
val reachedEnd = page.size < WalletConstants.PAYMENTS_PAGE_SIZE
if (reachedOverlap || reachedEnd) {
Log.d(TAG, "PAYMENTS [SYNC DONE ] $totalNew new payment(s) across $pagesChecked page(s) — " +
if (reachedOverlap) "stopped at known record" else "reached end of history")
break
}
syncOffset += page.size
}
// Reload first page from DB to reflect any newly merged payments
val refreshed = paymentCache.loadCachedPage(
offset = 0,
limit = WalletConstants.PAYMENTS_PAGE_SIZE
)
currentOffset = refreshed.size
_state.value = HistoryState.Success(
payments = refreshed,
canLoadMore = refreshed.size >= WalletConstants.PAYMENTS_PAGE_SIZE,
isRefreshing = false
)
}
// ── Pull-to-refresh ───────────────────────────────────────────────────────
fun refresh() {
loadJob?.cancel()
@@ -63,58 +161,50 @@ class HistoryViewModel(
} else {
_state.value = HistoryState.Loading
}
loadPage()
viewModelScope.launch { syncNewPayments() }
}
// ── Load more (pagination) ────────────────────────────────────────────────
fun loadMore() {
if (loadJob?.isActive == true) return
val current = _state.value
if (current is HistoryState.Success && !current.canLoadMore) return
loadPage()
}
if (current !is HistoryState.Success || !current.canLoadMore) return
private fun loadPage() {
loadJob = viewModelScope.launch {
runCatching {
repo.getPayments(
offset = currentOffset,
limit = WalletConstants.PAYMENTS_PAGE_SIZE
)
}.onSuccess { newPage ->
val canLoadMore = newPage.size >= WalletConstants.PAYMENTS_PAGE_SIZE
val existing = (_state.value as? HistoryState.Success)?.payments ?: emptyList()
val combined = if (currentOffset == 0) newPage else existing + newPage
val deduplicated = combined.distinctBy { it.paymentHash }
currentOffset += newPage.size
val previousCount = existing.size
val newCount = deduplicated.size
if (currentOffset <= WalletConstants.PAYMENTS_PAGE_SIZE) {
when {
previousCount == 0 -> Log.d("HistoryViewModel", "PAYMENTS [NETWORK ] Cold load — got ${newPage.size} payments")
newCount > previousCount -> Log.d("HistoryViewModel", "PAYMENTS [NETWORK ] ${newCount - previousCount} new payment(s) since cache")
else -> Log.d("HistoryViewModel", "PAYMENTS [NETWORK ] Cache was up to date — no new payments")
}
}
// Try DB first — if we have a full page cached, serve it instantly
val fromDb = paymentCache.loadCachedPage(
offset = currentOffset,
limit = WalletConstants.PAYMENTS_PAGE_SIZE
)
if (fromDb.size == WalletConstants.PAYMENTS_PAGE_SIZE) {
Log.d(TAG, "PAYMENTS [MORE DB ] offset=$currentOffset — served ${fromDb.size} rows from Room")
val deduplicated = (current.payments + fromDb).distinctBy { it.paymentHash }
currentOffset += fromDb.size
_state.value = HistoryState.Success(
payments = deduplicated,
canLoadMore = canLoadMore,
canLoadMore = true,
isRefreshing = false
)
return@launch
}
if (currentOffset <= WalletConstants.PAYMENTS_PAGE_SIZE) {
paymentCache.savePayments(deduplicated)
}
// Partial or empty DB page — fetch from network and merge
Log.d(TAG, "PAYMENTS [MORE NET ] offset=$currentOffset — DB has ${fromDb.size}, fetching from network")
runCatching {
repo.getPayments(offset = currentOffset, limit = WalletConstants.PAYMENTS_PAGE_SIZE)
}.onSuccess { newPage ->
paymentCache.mergePayments(newPage)
val deduplicated = (current.payments + newPage).distinctBy { it.paymentHash }
currentOffset += newPage.size
_state.value = HistoryState.Success(
payments = deduplicated,
canLoadMore = newPage.size >= WalletConstants.PAYMENTS_PAGE_SIZE,
isRefreshing = false
)
}.onFailure { e ->
val current = _state.value
if (current is HistoryState.Success) {
Log.w("HistoryViewModel", "PAYMENTS [NET ERROR ] Keeping cached list — ${e.message}")
_state.value = current.copy(isRefreshing = false)
} else {
Log.w("HistoryViewModel", "PAYMENTS [NET ERROR ] No cache to fall back on — ${e.message}")
_state.value = HistoryState.Error(e.message ?: "Failed to load payments")
}
Log.w(TAG, "PAYMENTS [MORE ERR ] ${e.message}")
_state.value = current.copy(isRefreshing = false)
}
}
}
@@ -125,29 +215,41 @@ class HistoryViewModel(
val detailState: StateFlow<DetailState> = _detailState
fun loadDetail(checkingId: String, record: PaymentRecord? = null) {
// Tier 1: in-memory cache
paymentCache.getCachedDetail(checkingId)?.let {
Log.d("HistoryViewModel", "PAYMENTS [DETAIL HIT] $checkingId — instant from cache")
Log.d(TAG, "PAYMENTS [DETAIL HIT] $checkingId — instant from cache")
_detailState.value = DetailState.Success(it)
return
}
// Show partial UI immediately
if (record != null) {
Log.d("HistoryViewModel", "PAYMENTS [DETAIL PAR] $checkingId — showing partial from PaymentRecord")
Log.d(TAG, "PAYMENTS [DETAIL PAR] $checkingId — showing partial from PaymentRecord")
_detailState.value = DetailState.Partial(record)
} else {
Log.d("HistoryViewModel", "PAYMENTS [DETAIL LOD] $checkingId — no record, showing spinner")
Log.d(TAG, "PAYMENTS [DETAIL LOD] $checkingId — no record, showing spinner")
_detailState.value = DetailState.Loading
}
viewModelScope.launch {
// Tier 2: Room DB
val fromDb = paymentCache.getDetailFromDb(checkingId)
if (fromDb != null) {
paymentCache.saveDetail(checkingId, fromDb)
_detailState.value = DetailState.Success(fromDb)
Log.d(TAG, "PAYMENTS [DETAIL DB ] $checkingId — served from Room, skipping network")
return@launch
}
// Tier 3: network
runCatching {
repo.getPaymentDetail(checkingId)
}.onSuccess { detail ->
Log.d("HistoryViewModel", "PAYMENTS [DETAIL NET] $checkingId — fetched from network")
Log.d(TAG, "PAYMENTS [DETAIL NET] $checkingId — fetched from network")
paymentCache.saveDetail(checkingId, detail)
_detailState.value = DetailState.Success(detail)
}.onFailure { e ->
Log.w("HistoryViewModel", "PAYMENTS [DETAIL ERR] $checkingId${e.message}")
Log.w(TAG, "PAYMENTS [DETAIL ERR] $checkingId${e.message}")
if (_detailState.value !is DetailState.Partial) {
_detailState.value = DetailState.Error(e.message ?: "Failed to load payment")
}
@@ -155,11 +257,9 @@ class HistoryViewModel(
}
}
fun clearDetail() {
_detailState.value = DetailState.Idle
}
fun clearDetail() { _detailState.value = DetailState.Idle }
// ── Destination pubkey + alias resolution ─────────────────────────────────
// ── Destination resolution ────────────────────────────────────────────────
val nodeAliases: StateFlow<Map<String, String>> = aliasRepo.aliases
@@ -176,8 +276,13 @@ class HistoryViewModel(
if (pubkey != null) aliasRepo.resolve(pubkey)
}
}
companion object {
private const val TAG = "HistoryViewModel"
}
}
// ── Factory ───────────────────────────────────────────────────────────────────
class HistoryViewModelFactory(