feat: add search by nodeid and alias
This commit is contained in:
@@ -168,7 +168,9 @@ data class PaymentRecord(
|
||||
@SerializedName("preimage") val preimage: String?,
|
||||
@SerializedName("extra") val extra: PaymentExtra?,
|
||||
// Not from the API — populated only when loaded from Room via toDomain()
|
||||
val createdAt: Long? = null
|
||||
val createdAt: Long? = null,
|
||||
val pubkey: String? = null,
|
||||
val alias: String? = null
|
||||
) {
|
||||
val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L
|
||||
val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L
|
||||
|
||||
@@ -150,6 +150,10 @@ class PaymentCacheRepository(context: Context) {
|
||||
suspend fun containsPayment(checkingId: String): Boolean =
|
||||
dao.getByCheckingId(checkingId) != null
|
||||
|
||||
suspend fun updatePubkeyAlias(paymentHash: String, pubkey: String?, alias: String?) {
|
||||
dao.updatePubkeyAlias(paymentHash, pubkey, alias)
|
||||
}
|
||||
|
||||
/** Clear everything — useful for testing and logout. */
|
||||
suspend fun clearAll() {
|
||||
detailCache.clear()
|
||||
|
||||
@@ -14,7 +14,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
LnurlCacheEntity::class,
|
||||
NodeAliasCacheEntity::class,
|
||||
],
|
||||
version = 4, // ← bumped
|
||||
version = 5, // ← bumped
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(PaymentConverters::class)
|
||||
@@ -70,6 +70,13 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE payment_records ADD COLUMN pubkey TEXT")
|
||||
db.execSQL("ALTER TABLE payment_records ADD COLUMN alias TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
fun getInstance(context: Context): AppDatabase =
|
||||
INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
|
||||
@@ -36,6 +36,9 @@ interface PaymentDao {
|
||||
@Query("DELETE FROM payment_records")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Query("UPDATE payment_records SET pubkey = :pubkey, alias = :alias WHERE paymentHash = :paymentHash")
|
||||
suspend fun updatePubkeyAlias(paymentHash: String, pubkey: String?, alias: String?)
|
||||
|
||||
/**
|
||||
* Full-table query used when search or filter is active.
|
||||
* All parameters are optional — pass null to skip that condition.
|
||||
@@ -56,7 +59,9 @@ interface PaymentDao {
|
||||
lower(memo) LIKE '%' || lower(:searchQuery) || '%' OR
|
||||
lower(paymentHash) LIKE '%' || lower(:searchQuery) || '%' OR
|
||||
lower(bolt11) LIKE '%' || lower(:searchQuery) || '%' OR
|
||||
lower(preimage) LIKE '%' || lower(:searchQuery) || '%'
|
||||
lower(preimage) LIKE '%' || lower(:searchQuery) || '%' OR
|
||||
lower(pubkey) LIKE '%' || lower(:searchQuery) || '%' OR
|
||||
lower(alias) LIKE '%' || lower(:searchQuery) || '%'
|
||||
))
|
||||
AND (:statusPattern IS NULL OR
|
||||
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.bitcointxoko.gudariwallet.data.db
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
@@ -20,5 +21,7 @@ data class PaymentRecordEntity(
|
||||
val pending: Boolean,
|
||||
val preimage: String?,
|
||||
val extra: String?, // JSON blob — PaymentExtra serialised by TypeConverter
|
||||
val savedAt: Long // epoch ms — used for TTL check
|
||||
val savedAt: Long, // epoch ms — used for TTL check
|
||||
@ColumnInfo(name = "pubkey") val pubkey: String? = null,
|
||||
@ColumnInfo(name = "alias") val alias: String? = null
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.bitcointxoko.gudariwallet.data.db
|
||||
|
||||
import android.util.Log
|
||||
import com.bitcointxoko.gudariwallet.api.GsonProvider.gson
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
|
||||
|
||||
@@ -30,13 +31,15 @@ fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity =
|
||||
feeMsat = feeMsat,
|
||||
memo = memo,
|
||||
time = time,
|
||||
createdAt = parseCreatedAt(time), // ← NEW
|
||||
createdAt = parseCreatedAt(time),
|
||||
status = status,
|
||||
bolt11 = bolt11,
|
||||
pending = pending,
|
||||
preimage = preimage,
|
||||
extra = extra?.let { com.bitcointxoko.gudariwallet.api.GsonProvider.gson.toJson(it) },
|
||||
savedAt = savedAt
|
||||
extra = extra?.let { gson.toJson(it) },
|
||||
savedAt = savedAt,
|
||||
pubkey = pubkey,
|
||||
alias = alias
|
||||
)
|
||||
|
||||
// ── PaymentRecordEntity → PaymentRecord ──────────────────────────────────────
|
||||
@@ -49,13 +52,12 @@ fun PaymentRecordEntity.toDomain(): PaymentRecord =
|
||||
feeMsat = feeMsat,
|
||||
memo = memo,
|
||||
time = time,
|
||||
createdAt = createdAt, // ← NEW
|
||||
createdAt = createdAt,
|
||||
status = status,
|
||||
bolt11 = bolt11,
|
||||
pending = pending,
|
||||
preimage = preimage,
|
||||
extra = extra?.let {
|
||||
com.bitcointxoko.gudariwallet.api.GsonProvider.gson
|
||||
.fromJson(it, com.bitcointxoko.gudariwallet.api.PaymentExtra::class.java)
|
||||
}
|
||||
extra = extra?.let { gson.fromJson(it, com.bitcointxoko.gudariwallet.api.PaymentExtra::class.java) },
|
||||
pubkey = pubkey,
|
||||
alias = alias
|
||||
)
|
||||
|
||||
@@ -190,7 +190,7 @@ fun HistoryScreen(
|
||||
// ── Active filter chips ───────────────────────────────────────────
|
||||
val searchActive = searchQuery.isNotBlank()
|
||||
|
||||
if (isFiltered) {
|
||||
if (isFiltered || searchActive) {
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -718,6 +718,17 @@ private fun SearchInfoSheet(visible: Boolean, onDismiss: () -> Unit) {
|
||||
description = "A secret code that proves a payment was received. " +
|
||||
"Only available after a payment completes successfully."
|
||||
)
|
||||
SearchInfoItem(
|
||||
title = "Node alias",
|
||||
description = "The name of the Lightning node you sent a payment to. " +
|
||||
"For example, \"ACINQ\" or \"Wallet of Satoshi\". " +
|
||||
"Aliases are resolved automatically for outgoing payments."
|
||||
)
|
||||
SearchInfoItem(
|
||||
title = "Node ID (pubkey)",
|
||||
description = "The public key of the destination node. " +
|
||||
"A 66-character hex string — you can paste just the first few characters."
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
@@ -941,13 +952,8 @@ private fun PaymentDetailContent(
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val destinationMap by vm.destinationState.collectAsState()
|
||||
val bolt11 = payment.bolt11?.takeIf { it.isNotBlank() && payment.isOutgoing }
|
||||
LaunchedEffect(payment.paymentHash) {
|
||||
if (bolt11 != null) vm.resolveDestination(bolt11, payment.paymentHash)
|
||||
}
|
||||
val pubkey = destinationMap[payment.paymentHash]
|
||||
val aliases by vm.nodeAliases.collectAsState()
|
||||
val pubkey = payment.pubkey
|
||||
val alias = payment.alias
|
||||
|
||||
// Pre-compute fiat strings for hero + fee
|
||||
val heroFiat: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
|
||||
@@ -1061,7 +1067,7 @@ private fun PaymentDetailContent(
|
||||
}
|
||||
if (pubkey != null) {
|
||||
val ambossUrl = "https://amboss.space/node/$pubkey"
|
||||
val label = aliases[pubkey] ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}"
|
||||
val label = alias ?: "${pubkey!!.take(8)}…${pubkey.takeLast(8)}"
|
||||
DetailRow(
|
||||
label = "Destination",
|
||||
value = label,
|
||||
|
||||
@@ -260,37 +260,6 @@ class HistoryViewModel(
|
||||
private var currentOffset = 0
|
||||
private var loadJob: Job? = null
|
||||
|
||||
init {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncNewPayments(maxPayments: Int = WalletConstants.PAYMENTS_SYNC_CHECKPOINT) {
|
||||
var syncOffset = 0
|
||||
var totalNew = 0
|
||||
@@ -350,11 +319,14 @@ class HistoryViewModel(
|
||||
)
|
||||
val merged = (refreshed + ((_state.value as? HistoryState.Success)?.payments ?: emptyList()))
|
||||
.distinctBy { it.paymentHash }
|
||||
_state.value = HistoryState.Success(
|
||||
val newState = HistoryState.Success(
|
||||
payments = merged,
|
||||
canLoadMore = refreshed.size >= currentOffset.coerceAtLeast(WalletConstants.PAYMENTS_PAGE_SIZE),
|
||||
canLoadMore = refreshed.size >=
|
||||
currentOffset.coerceAtLeast(WalletConstants.PAYMENTS_PAGE_SIZE),
|
||||
isRefreshing = false
|
||||
)
|
||||
_state.value = newState
|
||||
enrichPayments(newState.payments)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
@@ -386,11 +358,13 @@ class HistoryViewModel(
|
||||
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(
|
||||
val newState = HistoryState.Success(
|
||||
payments = deduplicated,
|
||||
canLoadMore = true,
|
||||
isRefreshing = false
|
||||
)
|
||||
_state.value = newState
|
||||
enrichPayments(newState.payments)
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -401,11 +375,14 @@ class HistoryViewModel(
|
||||
paymentCache.mergePayments(newPage)
|
||||
val deduplicated = (current.payments + newPage).distinctBy { it.paymentHash }
|
||||
currentOffset += newPage.size
|
||||
_state.value = HistoryState.Success(
|
||||
val newState = HistoryState.Success(
|
||||
payments = deduplicated,
|
||||
canLoadMore = newPage.size >= WalletConstants.PAYMENTS_PAGE_SIZE,
|
||||
canLoadMore = newPage.size >=
|
||||
WalletConstants.PAYMENTS_PAGE_SIZE,
|
||||
isRefreshing = false
|
||||
)
|
||||
_state.value = newState
|
||||
enrichPayments(newState.payments)
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "PAYMENTS [MORE ERR ] ${e.message}")
|
||||
_state.value = current.copy(isRefreshing = false)
|
||||
@@ -413,8 +390,37 @@ class HistoryViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Payment detail ────────────────────────────────────────────────────────
|
||||
// ── Enrichment (pubkey + alias) ────────────────────────────────────────────
|
||||
private fun enrichPayments(payments: List<PaymentRecord>) {
|
||||
val candidates = payments.filter { it.isOutgoing }
|
||||
if (candidates.isEmpty()) return
|
||||
val toEnrich = candidates.filter { !it.bolt11.isNullOrBlank() && it.pubkey == null }
|
||||
Log.d(TAG, "ENRICH [START ] ${toEnrich.size} payments to enrich " +
|
||||
"(${candidates.size - toEnrich.size} already enriched or no bolt11)")
|
||||
viewModelScope.launch {
|
||||
for (payment in toEnrich) {
|
||||
val pubkey = runCatching {
|
||||
repo.decodeBolt11(payment.bolt11!!).payee
|
||||
}.getOrNull() ?: continue
|
||||
val alias = runCatching {
|
||||
aliasRepo.resolve(pubkey)
|
||||
}.getOrNull()
|
||||
paymentCache.updatePubkeyAlias(payment.paymentHash, pubkey, alias)
|
||||
val current = _state.value
|
||||
if (current is HistoryState.Success) {
|
||||
_state.value = current.copy(
|
||||
payments = current.payments.map { p ->
|
||||
if (p.paymentHash == payment.paymentHash)
|
||||
p.copy(pubkey = pubkey, alias = alias)
|
||||
else p
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Payment detail ────────────────────────────────────────────────────────
|
||||
private val _detailState = MutableStateFlow<DetailState>(DetailState.Idle)
|
||||
val detailState: StateFlow<DetailState> = _detailState
|
||||
|
||||
@@ -458,22 +464,6 @@ class HistoryViewModel(
|
||||
|
||||
// ── Destination resolution ────────────────────────────────────────────────
|
||||
|
||||
val nodeAliases: StateFlow<Map<String, String>> = aliasRepo.aliases
|
||||
|
||||
private val _destinationState = MutableStateFlow<Map<String, String?>>(emptyMap())
|
||||
val destinationState: StateFlow<Map<String, String?>> = _destinationState
|
||||
|
||||
fun resolveDestination(bolt11: String, paymentHash: String) {
|
||||
if (_destinationState.value.containsKey(paymentHash)) return
|
||||
viewModelScope.launch {
|
||||
val pubkey = runCatching { repo.decodeBolt11(bolt11) }
|
||||
.getOrNull()
|
||||
?.payee
|
||||
_destinationState.update { it + (paymentHash to pubkey) }
|
||||
if (pubkey != null) aliasRepo.resolve(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HistoryViewModel"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user