feat: make search backed by room

This commit is contained in:
2026-06-03 16:33:45 +02:00
parent 0f932b0229
commit fe4b387a27
3 changed files with 206 additions and 59 deletions
@@ -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<PaymentRecord> {
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<PaymentRecord>) {
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<PaymentRecord> {
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")
@@ -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<PaymentRecordEntity>
}
@@ -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,102 @@ 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<List<PaymentRecord>?>(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<HistoryState> = combine(
_state, _filter, _searchQuery, _roomResults
) { s, f, _, roomResults ->
/** The list shown in the UI — raw state with filter applied. */
val filteredState: StateFlow<HistoryState> = combine(_state, _filter, _searchQuery) { s, f, q ->
if (s !is HistoryState.Success) return@combine s
// ── 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
@@ -135,55 +228,35 @@ class HistoryViewModel(
}
}
.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)
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
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 }
}
.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