feat: add search by nodeid and alias

This commit is contained in:
2026-06-03 18:11:47 +02:00
parent fe4b387a27
commit 4e58681919
8 changed files with 93 additions and 74 deletions
@@ -168,7 +168,9 @@ data class PaymentRecord(
@SerializedName("preimage") val preimage: String?, @SerializedName("preimage") val preimage: String?,
@SerializedName("extra") val extra: PaymentExtra?, @SerializedName("extra") val extra: PaymentExtra?,
// Not from the API — populated only when loaded from Room via toDomain() // 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 amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L
val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L
@@ -150,6 +150,10 @@ class PaymentCacheRepository(context: Context) {
suspend fun containsPayment(checkingId: String): Boolean = suspend fun containsPayment(checkingId: String): Boolean =
dao.getByCheckingId(checkingId) != null 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. */ /** Clear everything — useful for testing and logout. */
suspend fun clearAll() { suspend fun clearAll() {
detailCache.clear() detailCache.clear()
@@ -14,7 +14,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
LnurlCacheEntity::class, LnurlCacheEntity::class,
NodeAliasCacheEntity::class, NodeAliasCacheEntity::class,
], ],
version = 4, // ← bumped version = 5, // ← bumped
exportSchema = false exportSchema = false
) )
@TypeConverters(PaymentConverters::class) @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 = fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
@@ -36,6 +36,9 @@ interface PaymentDao {
@Query("DELETE FROM payment_records") @Query("DELETE FROM payment_records")
suspend fun deleteAll() 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. * Full-table query used when search or filter is active.
* All parameters are optional — pass null to skip that condition. * All parameters are optional — pass null to skip that condition.
@@ -56,7 +59,9 @@ interface PaymentDao {
lower(memo) LIKE '%' || lower(:searchQuery) || '%' OR lower(memo) LIKE '%' || lower(:searchQuery) || '%' OR
lower(paymentHash) LIKE '%' || lower(:searchQuery) || '%' OR lower(paymentHash) LIKE '%' || lower(:searchQuery) || '%' OR
lower(bolt11) 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 AND (:statusPattern IS NULL OR
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%') ',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
@@ -1,5 +1,6 @@
package com.bitcointxoko.gudariwallet.data.db package com.bitcointxoko.gudariwallet.data.db
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.TypeConverters import androidx.room.TypeConverters
@@ -20,5 +21,7 @@ data class PaymentRecordEntity(
val pending: Boolean, val pending: Boolean,
val preimage: String?, val preimage: String?,
val extra: String?, // JSON blob — PaymentExtra serialised by TypeConverter 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 package com.bitcointxoko.gudariwallet.data.db
import android.util.Log import android.util.Log
import com.bitcointxoko.gudariwallet.api.GsonProvider.gson
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
@@ -30,13 +31,15 @@ fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity =
feeMsat = feeMsat, feeMsat = feeMsat,
memo = memo, memo = memo,
time = time, time = time,
createdAt = parseCreatedAt(time), // ← NEW createdAt = parseCreatedAt(time),
status = status, status = status,
bolt11 = bolt11, bolt11 = bolt11,
pending = pending, pending = pending,
preimage = preimage, preimage = preimage,
extra = extra?.let { com.bitcointxoko.gudariwallet.api.GsonProvider.gson.toJson(it) }, extra = extra?.let { gson.toJson(it) },
savedAt = savedAt savedAt = savedAt,
pubkey = pubkey,
alias = alias
) )
// ── PaymentRecordEntity → PaymentRecord ────────────────────────────────────── // ── PaymentRecordEntity → PaymentRecord ──────────────────────────────────────
@@ -49,13 +52,12 @@ fun PaymentRecordEntity.toDomain(): PaymentRecord =
feeMsat = feeMsat, feeMsat = feeMsat,
memo = memo, memo = memo,
time = time, time = time,
createdAt = createdAt, // ← NEW createdAt = createdAt,
status = status, status = status,
bolt11 = bolt11, bolt11 = bolt11,
pending = pending, pending = pending,
preimage = preimage, preimage = preimage,
extra = extra?.let { extra = extra?.let { gson.fromJson(it, com.bitcointxoko.gudariwallet.api.PaymentExtra::class.java) },
com.bitcointxoko.gudariwallet.api.GsonProvider.gson pubkey = pubkey,
.fromJson(it, com.bitcointxoko.gudariwallet.api.PaymentExtra::class.java) alias = alias
}
) )
@@ -190,7 +190,7 @@ fun HistoryScreen(
// ── Active filter chips ─────────────────────────────────────────── // ── Active filter chips ───────────────────────────────────────────
val searchActive = searchQuery.isNotBlank() val searchActive = searchQuery.isNotBlank()
if (isFiltered) { if (isFiltered || searchActive) {
FlowRow( FlowRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -718,6 +718,17 @@ private fun SearchInfoSheet(visible: Boolean, onDismiss: () -> Unit) {
description = "A secret code that proves a payment was received. " + description = "A secret code that proves a payment was received. " +
"Only available after a payment completes successfully." "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)) Spacer(Modifier.height(16.dp))
Text( Text(
@@ -941,13 +952,8 @@ private fun PaymentDetailContent(
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val destinationMap by vm.destinationState.collectAsState() val pubkey = payment.pubkey
val bolt11 = payment.bolt11?.takeIf { it.isNotBlank() && payment.isOutgoing } val alias = payment.alias
LaunchedEffect(payment.paymentHash) {
if (bolt11 != null) vm.resolveDestination(bolt11, payment.paymentHash)
}
val pubkey = destinationMap[payment.paymentHash]
val aliases by vm.nodeAliases.collectAsState()
// Pre-compute fiat strings for hero + fee // Pre-compute fiat strings for hero + fee
val heroFiat: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) { val heroFiat: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
@@ -1061,7 +1067,7 @@ private fun PaymentDetailContent(
} }
if (pubkey != null) { if (pubkey != null) {
val ambossUrl = "https://amboss.space/node/$pubkey" 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( DetailRow(
label = "Destination", label = "Destination",
value = label, value = label,
@@ -260,37 +260,6 @@ class HistoryViewModel(
private var currentOffset = 0 private var currentOffset = 0
private var loadJob: Job? = null 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) { private suspend fun syncNewPayments(maxPayments: Int = WalletConstants.PAYMENTS_SYNC_CHECKPOINT) {
var syncOffset = 0 var syncOffset = 0
var totalNew = 0 var totalNew = 0
@@ -350,11 +319,14 @@ class HistoryViewModel(
) )
val merged = (refreshed + ((_state.value as? HistoryState.Success)?.payments ?: emptyList())) val merged = (refreshed + ((_state.value as? HistoryState.Success)?.payments ?: emptyList()))
.distinctBy { it.paymentHash } .distinctBy { it.paymentHash }
_state.value = HistoryState.Success( val newState = HistoryState.Success(
payments = merged, payments = merged,
canLoadMore = refreshed.size >= currentOffset.coerceAtLeast(WalletConstants.PAYMENTS_PAGE_SIZE), canLoadMore = refreshed.size >=
currentOffset.coerceAtLeast(WalletConstants.PAYMENTS_PAGE_SIZE),
isRefreshing = false isRefreshing = false
) )
_state.value = newState
enrichPayments(newState.payments)
} }
fun refresh() { fun refresh() {
@@ -386,11 +358,13 @@ class HistoryViewModel(
Log.d(TAG, "PAYMENTS [MORE DB ] offset=$currentOffset — served ${fromDb.size} rows from Room") Log.d(TAG, "PAYMENTS [MORE DB ] offset=$currentOffset — served ${fromDb.size} rows from Room")
val deduplicated = (current.payments + fromDb).distinctBy { it.paymentHash } val deduplicated = (current.payments + fromDb).distinctBy { it.paymentHash }
currentOffset += fromDb.size currentOffset += fromDb.size
_state.value = HistoryState.Success( val newState = HistoryState.Success(
payments = deduplicated, payments = deduplicated,
canLoadMore = true, canLoadMore = true,
isRefreshing = false isRefreshing = false
) )
_state.value = newState
enrichPayments(newState.payments)
return@launch return@launch
} }
@@ -401,11 +375,14 @@ class HistoryViewModel(
paymentCache.mergePayments(newPage) paymentCache.mergePayments(newPage)
val deduplicated = (current.payments + newPage).distinctBy { it.paymentHash } val deduplicated = (current.payments + newPage).distinctBy { it.paymentHash }
currentOffset += newPage.size currentOffset += newPage.size
_state.value = HistoryState.Success( val newState = HistoryState.Success(
payments = deduplicated, payments = deduplicated,
canLoadMore = newPage.size >= WalletConstants.PAYMENTS_PAGE_SIZE, canLoadMore = newPage.size >=
WalletConstants.PAYMENTS_PAGE_SIZE,
isRefreshing = false isRefreshing = false
) )
_state.value = newState
enrichPayments(newState.payments)
}.onFailure { e -> }.onFailure { e ->
Log.w(TAG, "PAYMENTS [MORE ERR ] ${e.message}") Log.w(TAG, "PAYMENTS [MORE ERR ] ${e.message}")
_state.value = current.copy(isRefreshing = false) _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) private val _detailState = MutableStateFlow<DetailState>(DetailState.Idle)
val detailState: StateFlow<DetailState> = _detailState val detailState: StateFlow<DetailState> = _detailState
@@ -458,22 +464,6 @@ class HistoryViewModel(
// ── Destination resolution ──────────────────────────────────────────────── // ── 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 { companion object {
private const val TAG = "HistoryViewModel" private const val TAG = "HistoryViewModel"