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("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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user