diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt index 7746cd5..6c90283 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt @@ -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 diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt index 858fb41..707e18b 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt @@ -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() diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt index 787a5fc..44aac37 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt @@ -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( diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt index 3228fc4..328504a 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt @@ -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) || ',%') diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordEntity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordEntity.kt index 66ae689..6daff05 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordEntity.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordEntity.kt @@ -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 ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordMapper.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordMapper.kt index 284e41d..310f900 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordMapper.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentRecordMapper.kt @@ -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 ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt index 8a9b22d..9a251b8 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryScreen.kt @@ -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, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt index 46bb087..e2d300e 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HistoryViewModel.kt @@ -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) { + 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.Idle) val detailState: StateFlow = _detailState @@ -458,22 +464,6 @@ class HistoryViewModel( // ── Destination resolution ──────────────────────────────────────────────── - val nodeAliases: StateFlow> = aliasRepo.aliases - - private val _destinationState = MutableStateFlow>(emptyMap()) - val destinationState: StateFlow> = _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"