From fe4b387a27d7bb963a24a7bfc11b503522690a07 Mon Sep 17 00:00:00 2001 From: rasputin Date: Wed, 3 Jun 2026 16:33:45 +0200 Subject: [PATCH] feat: make search backed by room --- .../data/PaymentCacheRepository.kt | 54 ++++-- .../gudariwallet/data/db/PaymentDao.kt | 39 ++++ .../gudariwallet/ui/HistoryViewModel.kt | 172 +++++++++++++----- 3 files changed, 206 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt index 3af1b04..858fb41 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt @@ -8,6 +8,8 @@ import com.bitcointxoko.gudariwallet.data.db.AppDatabase import com.bitcointxoko.gudariwallet.data.db.toDomain import com.bitcointxoko.gudariwallet.data.db.toEntity import com.bitcointxoko.gudariwallet.util.WalletConstants +import com.bitcointxoko.gudariwallet.ui.PaymentFilter +import com.bitcointxoko.gudariwallet.ui.StatusFilter private const val TAG = "PaymentCacheRepository" @@ -36,10 +38,7 @@ class PaymentCacheRepository(context: Context) { } } - /** - * Load a page of payments from the DB. - * Returns empty list if nothing cached yet. - */ + /** Load a page of payments from the DB. Returns empty list if nothing cached yet. */ suspend fun loadCachedPage(offset: Int, limit: Int): List { return dao.getPage(limit = limit, offset = offset) .map { it.toDomain() } @@ -54,11 +53,7 @@ class PaymentCacheRepository(context: Context) { /** Total number of payment rows in the DB. */ suspend fun countCached(): Int = dao.count() - /** - * Merge a fetched page into the DB. - * Uses INSERT OR REPLACE — never deletes existing rows. - * Safe to call for any page, not just the first. - */ + /** Merge a fetched page into the DB. Uses INSERT OR REPLACE — never deletes existing rows. */ suspend fun mergePayments(payments: List) { if (payments.isEmpty()) return val now = System.currentTimeMillis() @@ -82,6 +77,44 @@ class PaymentCacheRepository(context: Context) { } } + /** + * Query the full Room table using any combination of search + filter. + * Called by HistoryViewModel whenever search or a SQL-capable filter is active. + * Direction and type filters are NOT applied here — they're handled in-memory + * in the ViewModel after this call returns. + */ + suspend fun queryAll(query: String, filter: PaymentFilter): List { + val searchQuery = query.trim().ifBlank { null } + + // Build a comma-joined status pattern, e.g. "success,complete,paid,pending,in_flight,inflight" + // null means "no status filter" — Room will skip that WHERE clause entirely. + val statusPattern: String? = if (filter.statuses.isEmpty()) null else { + filter.statuses.flatMap { s -> + when (s) { + StatusFilter.COMPLETED -> listOf("success", "complete", "paid") + StatusFilter.PENDING -> listOf("pending", "in_flight", "inflight") + StatusFilter.FAILED -> listOf("failed", "error", "expired") + } + }.joinToString(",") + } + + val minAmountMsat = filter.minAmountSat?.let { it * 1000L } + val maxAmountMsat = filter.maxAmountSat?.let { it * 1000L } + + return dao.queryAll( + searchQuery = searchQuery, + statusPattern = statusPattern, + minAmountMsat = minAmountMsat, + maxAmountMsat = maxAmountMsat, + minCreatedAt = filter.minCreatedAt, + maxCreatedAt = filter.maxCreatedAt + ).map { it.toDomain() }.also { + Log.d(TAG, "PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " + + "amt=${filter.minAmountSat}–${filter.maxAmountSat} " + + "date=${filter.minCreatedAt}–${filter.maxCreatedAt} → ${it.size} rows") + } + } + // ── Payment detail ──────────────────────────────────────────────────────── /** Returns a cached detail response, or null if not yet fetched this session. */ @@ -103,8 +136,7 @@ class PaymentCacheRepository(context: Context) { Log.d(TAG, "PAYMENTS [UPSERT ] ${payment.checkingId}") } - /** Check the DB for a stored payment record and return it as a detail response. - * Returns null if not found. Never hits the network. */ + /** Check the DB for a stored payment record and return it as a detail response. */ suspend fun getDetailFromDb(checkingId: String): PaymentDetailResponse? { val entity = dao.getByCheckingId(checkingId) ?: return null Log.d(TAG, "PAYMENTS [DETAIL DB ] $checkingId — found in Room") diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt index 56a894c..3228fc4 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt @@ -35,4 +35,43 @@ interface PaymentDao { @Query("DELETE FROM payment_records") suspend fun deleteAll() + + /** + * Full-table query used when search or filter is active. + * All parameters are optional — pass null to skip that condition. + * + * Notes: + * - status strings are passed as a comma-joined string and matched with LIKE + * because Room doesn't support passing a list to IN() without extra setup. + * We use ",success,complete,paid," style wrapping to avoid false matches. + * - amountMsat thresholds are passed in msat (sat * 1000), ABS() handles + * outgoing payments whose amountMsat is negative. + * - createdAt is epoch seconds, matching the stored column. + * - direction and type are handled in-memory post-query (no column for either). + */ + @Query(""" + SELECT * FROM payment_records + WHERE + (:searchQuery IS NULL OR ( + lower(memo) LIKE '%' || lower(:searchQuery) || '%' OR + lower(paymentHash) LIKE '%' || lower(:searchQuery) || '%' OR + lower(bolt11) LIKE '%' || lower(:searchQuery) || '%' OR + lower(preimage) LIKE '%' || lower(:searchQuery) || '%' + )) + AND (:statusPattern IS NULL OR + ',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%') + AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat) + AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat) + AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt) + AND (:maxCreatedAt IS NULL OR createdAt <= :maxCreatedAt) + ORDER BY createdAt DESC, time DESC + """) + suspend fun queryAll( + searchQuery : String?, + statusPattern : String?, // e.g. "success,complete,paid" or null + minAmountMsat : Long?, + maxAmountMsat : Long?, + minCreatedAt : Long?, + maxCreatedAt : Long? + ): List } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt index fb8025d..46bb087 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt @@ -10,11 +10,13 @@ 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.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -40,7 +42,7 @@ enum class DatePreset { THIS_WEEK, THIS_MONTH, THIS_YEAR } // ── ViewModel ───────────────────────────────────────────────────────────────── - +@OptIn(FlowPreview::class) class HistoryViewModel( private val repo : WalletRepository, private val aliasRepo : NodeAliasRepository, @@ -122,11 +124,121 @@ class HistoryViewModel( } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + // ── Room-backed query results ───────────────────────────────────────────── + + /** + * Holds the result of the last Room query, or null when no query/filter is + * active (i.e. we're on the fast in-memory path). + */ + private val _roomResults = MutableStateFlow?>(null) + + /** + * Returns true when at least one SQL-capable condition is active. + * Direction and type are excluded — they're always handled in-memory. + */ + private fun PaymentFilter.needsRoomQuery(): Boolean = + statuses.isNotEmpty() || + minAmountSat != null || maxAmountSat != null || + minCreatedAt != null || maxCreatedAt != null + + init { + // Watch search query (debounced) + filter for SQL-capable conditions. + // Whenever either changes and a Room query is needed, fire queryAll(). + // When neither is active, clear _roomResults to fall back to in-memory. + viewModelScope.launch { + combine( + _searchQuery.debounce(300), + _filter + ) { q, f -> q to f } + .collect { (q, f) -> + if (q.isBlank() && !f.needsRoomQuery()) { + Log.d(TAG, "SEARCH/FILTER [IN-MEMORY] q=blank filter=default → using cached _state") + _roomResults.value = null // back to in-memory path + } else { + Log.d(TAG, "SEARCH/FILTER [ROOM ] q=\"$q\" statuses=${f.statuses} " + + "dir=${f.direction} types=${f.types} " + + "amt=${f.minAmountSat}–${f.maxAmountSat} " + + "date=${f.minCreatedAt}–${f.maxCreatedAt}") + val results = paymentCache.queryAll(q, f) + _roomResults.value = results + } + } + } + + // Existing init block below — unchanged + viewModelScope.launch { + val cached = paymentCache.loadCachedPage( + offset = 0, + limit = WalletConstants.PAYMENTS_PAGE_SIZE + ) + if (cached.isNotEmpty()) { + Log.d(TAG, "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately") + currentOffset = cached.size + _state.value = HistoryState.Success( + payments = cached.distinctBy { it.paymentHash }, + canLoadMore = cached.size >= WalletConstants.PAYMENTS_PAGE_SIZE, + isRefreshing = true + ) + } else { + Log.d(TAG, "PAYMENTS [INIT ] No cache — cold load") + } + syncNewPayments() + } + 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() + } + } + } + + // ── filteredState ───────────────────────────────────────────────────────── + /** + * The list shown in the UI. + * + * Two paths: + * A) _roomResults == null → in-memory path (no search, no SQL-capable filter active) + * Fast, zero DB overhead, identical to the old behaviour. + * + * B) _roomResults != null → Room path (search or SQL-capable filter active) + * Base list comes from Room (full table, already filtered by SQL). + * Direction + type filters are then applied in-memory on top. + * canLoadMore is forced false — Room already returned everything. + */ + val filteredState: StateFlow = combine( + _state, _filter, _searchQuery, _roomResults + ) { s, f, _, roomResults -> - /** The list shown in the UI — raw state with filter applied. */ - val filteredState: StateFlow = combine(_state, _filter, _searchQuery) { s, f, q -> if (s !is HistoryState.Success) return@combine s - val filtered = s.payments + + // ── Path A: pure in-memory (search blank, no SQL-capable filter) ────── + if (roomResults == null) { + Log.d(TAG, "SEARCH/FILTER [IN-MEMORY] filteredState: base=${s.payments.size} payments") + val filtered = s.payments + .distinctBy { it.paymentHash } + .let { list -> + when (f.direction) { + DirectionFilter.ALL -> list + DirectionFilter.OUTGOING -> list.filter { it.isOutgoing } + DirectionFilter.INCOMING -> list.filter { !it.isOutgoing } + } + } + .let { list -> + if (f.types.isEmpty()) list + else list.filter { it.extra?.tag in f.types } + } + Log.d(TAG, "SEARCH/FILTER [IN-MEMORY] filteredState: after filters → ${filtered.size} shown") + return@combine s.copy(payments = filtered) + } + + // ── Path B: Room results — apply direction + type in-memory ─────────── + Log.d(TAG, "SEARCH/FILTER [ROOM ] filteredState: base=${roomResults.size} from Room") + val filtered = roomResults + .distinctBy { it.paymentHash } .let { list -> when (f.direction) { DirectionFilter.ALL -> list @@ -134,56 +246,17 @@ class HistoryViewModel( DirectionFilter.INCOMING -> list.filter { !it.isOutgoing } } } - .let { list -> - if (f.statuses.isEmpty()) list - else list.filter { payment -> - f.statuses.any { s -> - when (s) { - StatusFilter.COMPLETED -> isCompletedStatus(payment.status) - StatusFilter.PENDING -> isPendingStatus(payment.status) - StatusFilter.FAILED -> isFailedStatus(payment.status) - } - } - } - } .let { list -> if (f.types.isEmpty()) list else list.filter { it.extra?.tag in f.types } } - .let { list -> - if (f.minAmountSat == null && f.maxAmountSat == null) list - else list.filter { payment -> - val amt = payment.amountSat - (f.minAmountSat == null || amt >= f.minAmountSat) && - (f.maxAmountSat == null || amt <= f.maxAmountSat) - } - } - .let { list -> - if (f.minCreatedAt == null && f.maxCreatedAt == null) list - else list.filter { payment -> - val epoch = payment.createdAt - ?: payment.time.toLongOrNull() - ?: return@filter false - (f.minCreatedAt == null || epoch >= f.minCreatedAt) && - (f.maxCreatedAt == null || epoch <= f.maxCreatedAt) - } - } - .let { list -> - // ── Search (last stage, applied after all filters) ───────── - val query = q.lowercase() - if (query.isBlank()) list - else list.filter { payment -> - payment.bolt11?.lowercase()?.contains(query) == true || - payment.paymentHash.lowercase().contains(query) || - payment.memo?.lowercase()?.contains(query) == true || - payment.preimage?.lowercase()?.contains(query) == true - } - } - s.copy(payments = filtered) + + // canLoadMore = false: Room returned the full matching set already + Log.d(TAG, "SEARCH/FILTER [ROOM ] filteredState: after dir+type → ${filtered.size} shown") + s.copy(payments = filtered, canLoadMore = false) + }.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading) - - private var currentOffset = 0 private var loadJob: Job? = null @@ -298,6 +371,9 @@ class HistoryViewModel( fun loadMore() { if (loadJob?.isActive == true) return + // Guard against Room path — filteredState already returns canLoadMore=false + val filtered = filteredState.value + if (filtered !is HistoryState.Success || !filtered.canLoadMore) return val current = _state.value if (current !is HistoryState.Success || !current.canLoadMore) return