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.toDomain
import com.bitcointxoko.gudariwallet.data.db.toEntity import com.bitcointxoko.gudariwallet.data.db.toEntity
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.ui.PaymentFilter
import com.bitcointxoko.gudariwallet.ui.StatusFilter
private const val TAG = "PaymentCacheRepository" 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> { suspend fun loadCachedPage(offset: Int, limit: Int): List<PaymentRecord> {
return dao.getPage(limit = limit, offset = offset) return dao.getPage(limit = limit, offset = offset)
.map { it.toDomain() } .map { it.toDomain() }
@@ -54,11 +53,7 @@ class PaymentCacheRepository(context: Context) {
/** Total number of payment rows in the DB. */ /** Total number of payment rows in the DB. */
suspend fun countCached(): Int = dao.count() suspend fun countCached(): Int = dao.count()
/** /** Merge a fetched page into the DB. Uses INSERT OR REPLACE — never deletes existing rows. */
* 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.
*/
suspend fun mergePayments(payments: List<PaymentRecord>) { suspend fun mergePayments(payments: List<PaymentRecord>) {
if (payments.isEmpty()) return if (payments.isEmpty()) return
val now = System.currentTimeMillis() 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 ──────────────────────────────────────────────────────── // ── Payment detail ────────────────────────────────────────────────────────
/** Returns a cached detail response, or null if not yet fetched this session. */ /** 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}") Log.d(TAG, "PAYMENTS [UPSERT ] ${payment.checkingId}")
} }
/** Check the DB for a stored payment record and return it as a detail response. /** Check the DB for a stored payment record and return it as a detail response. */
* Returns null if not found. Never hits the network. */
suspend fun getDetailFromDb(checkingId: String): PaymentDetailResponse? { suspend fun getDetailFromDb(checkingId: String): PaymentDetailResponse? {
val entity = dao.getByCheckingId(checkingId) ?: return null val entity = dao.getByCheckingId(checkingId) ?: return null
Log.d(TAG, "PAYMENTS [DETAIL DB ] $checkingId — found in Room") Log.d(TAG, "PAYMENTS [DETAIL DB ] $checkingId — found in Room")
@@ -35,4 +35,43 @@ interface PaymentDao {
@Query("DELETE FROM payment_records") @Query("DELETE FROM payment_records")
suspend fun deleteAll() 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.data.WalletRepository
import com.bitcointxoko.gudariwallet.service.WalletNotificationService import com.bitcointxoko.gudariwallet.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -40,7 +42,7 @@ enum class DatePreset { THIS_WEEK, THIS_MONTH, THIS_YEAR }
// ── ViewModel ───────────────────────────────────────────────────────────────── // ── ViewModel ─────────────────────────────────────────────────────────────────
@OptIn(FlowPreview::class)
class HistoryViewModel( class HistoryViewModel(
private val repo : WalletRepository, private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository, private val aliasRepo : NodeAliasRepository,
@@ -122,11 +124,121 @@ class HistoryViewModel(
} }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) .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 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 -> .let { list ->
when (f.direction) { when (f.direction) {
DirectionFilter.ALL -> list DirectionFilter.ALL -> list
@@ -134,56 +246,17 @@ class HistoryViewModel(
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing } 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 -> .let { list ->
if (f.types.isEmpty()) list if (f.types.isEmpty()) list
else list.filter { it.extra?.tag in f.types } else list.filter { it.extra?.tag in f.types }
} }
.let { list ->
if (f.minAmountSat == null && f.maxAmountSat == null) list // canLoadMore = false: Room returned the full matching set already
else list.filter { payment -> Log.d(TAG, "SEARCH/FILTER [ROOM ] filteredState: after dir+type → ${filtered.size} shown")
val amt = payment.amountSat s.copy(payments = filtered, canLoadMore = false)
(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)
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading) }.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
private var currentOffset = 0 private var currentOffset = 0
private var loadJob: Job? = null private var loadJob: Job? = null
@@ -298,6 +371,9 @@ class HistoryViewModel(
fun loadMore() { fun loadMore() {
if (loadJob?.isActive == true) return 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 val current = _state.value
if (current !is HistoryState.Success || !current.canLoadMore) return if (current !is HistoryState.Success || !current.canLoadMore) return