From c005917e81995d01c4b0a1d693c145f1677b1422 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 30 Apr 2026 10:37:38 -0700 Subject: [PATCH 01/35] feat: add public contact payments --- CHANGELOG.md | 3 + .../main/java/to/bitkit/data/SettingsStore.kt | 2 + app/src/main/java/to/bitkit/env/Env.kt | 2 +- app/src/main/java/to/bitkit/ext/Activities.kt | 4 + .../to/bitkit/repositories/ActivityRepo.kt | 77 +++++ .../java/to/bitkit/repositories/PubkyRepo.kt | 44 ++- .../bitkit/repositories/PublicPaykitRepo.kt | 283 ++++++++++++++++++ .../to/bitkit/services/MigrationService.kt | 2 + .../java/to/bitkit/services/PubkyService.kt | 23 ++ app/src/main/java/to/bitkit/ui/ContentView.kt | 23 ++ .../ui/screens/contacts/AddContactScreen.kt | 24 ++ .../screens/contacts/AddContactViewModel.kt | 58 +++- .../screens/contacts/ContactActivityScreen.kt | 190 ++++++++++++ .../contacts/ContactActivityViewModel.kt | 116 +++++++ .../screens/contacts/ContactDetailScreen.kt | 30 +- .../contacts/ContactDetailViewModel.kt | 46 +++ .../ui/screens/contacts/ContactImportFlow.kt | 6 + .../ui/screens/contacts/ContactsScreen.kt | 1 + .../ui/screens/profile/PayContactsScreen.kt | 35 ++- .../screens/profile/PayContactsViewModel.kt | 102 +++++++ .../components/ActivityListGrouped.kt | 8 +- .../activity/components/ActivityRow.kt | 10 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 134 +++++++-- app/src/main/res/values/strings.xml | 6 + .../bitkit/repositories/ActivityRepoTest.kt | 1 + .../repositories/PublicPaykitRepoTest.kt | 143 +++++++++ .../contacts/AddContactViewModelTest.kt | 17 ++ .../screens/contacts/ContactImportFlowTest.kt | 13 + .../viewmodels/AppViewModelSendFlowTest.kt | 3 + gradle/libs.versions.toml | 4 +- 31 files changed, 1371 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt create mode 100644 app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index db4df41e2b..864c04704f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Support public Paykit contact payments #924 + ### Changed - Improve Pubky profile restore, contact editing, and contact routing flows #905 diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 965fd3da92..146955dfb0 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -100,6 +100,8 @@ data class SettingsData( val hasSeenShopIntro: Boolean = false, val hasSeenProfileIntro: Boolean = false, val hasSeenContactsIntro: Boolean = false, + val hasConfirmedPublicPaykitEndpoints: Boolean = false, + val sharesPublicPaykitEndpoints: Boolean = false, val quickPayIntroSeen: Boolean = false, val bgPaymentsIntroSeen: Boolean = false, val isQuickPayEnabled: Boolean = false, diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index edf0f08e6e..e3b4b76024 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -169,7 +169,7 @@ internal object Env { Network.BITCOIN -> "" else -> "staging." } - return "/pub/$pubkyDomain/:rw,/pub/${prefix}pubky.app/:r,/pub/${prefix}paykit/v0/:rw" + return "/pub/$pubkyDomain/:rw,/pub/${prefix}pubky.app/:r,/pub/paykit/v0/:rw" } val homegateUrl: String diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 7df94bc93f..39f50f23b7 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -92,6 +92,7 @@ fun LightningActivity.Companion.create( fee: ULong = 0u, message: String = "", preimage: String? = null, + contact: String? = null, createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, @@ -105,6 +106,7 @@ fun LightningActivity.Companion.create( message = message, timestamp = timestamp, preimage = preimage, + contact = contact, createdAt = createdAt, updatedAt = updatedAt, seenAt = seenAt, @@ -128,6 +130,7 @@ fun OnchainActivity.Companion.create( confirmTimestamp: ULong? = null, channelId: String? = null, transferTxId: String? = null, + contact: String? = null, createdAt: ULong? = timestamp, updatedAt: ULong? = createdAt, seenAt: ULong? = null, @@ -148,6 +151,7 @@ fun OnchainActivity.Companion.create( confirmTimestamp = confirmTimestamp, channelId = channelId, transferTxId = transferTxId, + contact = contact, createdAt = createdAt, updatedAt = updatedAt, seenAt = seenAt, diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 51df37d205..d21fe879f2 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -33,12 +33,14 @@ import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.data.CacheStore import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.di.BgDispatcher +import to.bitkit.di.IoDispatcher import to.bitkit.ext.amountOnClose import to.bitkit.ext.matchesPaymentId import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp import to.bitkit.ext.rawId import to.bitkit.models.ActivityBackupV1 +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -55,6 +57,7 @@ private const val MS_SYNC_TIMEOUT = 40_000L @Singleton class ActivityRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val coreService: CoreService, private val lightningRepo: LightningRepo, private val blocktankRepo: BlocktankRepo, @@ -338,6 +341,79 @@ class ActivityRepo @Inject constructor( } } + suspend fun contactActivities(publicKey: String): Result> = withContext(ioDispatcher) { + runCatching { + val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey + getActivities( + filter = ActivityFilter.ALL, + sortDirection = SortDirection.DESC, + ).getOrThrow().filter { activity -> + when (activity) { + is Activity.Lightning -> PubkyPublicKeyFormat.matches(activity.v1.contact, normalizedKey) + is Activity.Onchain -> PubkyPublicKeyFormat.matches(activity.v1.contact, normalizedKey) + } + } + }.onFailure { + Logger.error("Failed to load contact activities for '$publicKey'", it, context = TAG) + } + } + + suspend fun setContact( + contactPublicKey: String, + forPaymentId: String, + syncLdkPayments: Boolean = true, + ): Result = withContext(ioDispatcher) { + runCatching { + if (syncLdkPayments) { + lightningRepo.getPayments().onSuccess { + syncLdkNodePayments(it).getOrThrow() + }.getOrThrow() + } + + val normalizedKey = PubkyPublicKeyFormat.normalized(contactPublicKey) ?: contactPublicKey + val activity = findActivityForPaymentId(forPaymentId, syncLdkPayments) + if (activity == null) { + Logger.warn( + "Skipped setting contact for payment '$forPaymentId' because activity was not found", + context = TAG, + ) + return@runCatching + } + if (PubkyPublicKeyFormat.matches(activity.contact(), normalizedKey)) { + return@runCatching + } + + val updatedAt = nowTimestamp().epochSecond.toULong() + val updatedActivity = activity.withContact(normalizedKey, updatedAt) + updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() + }.onFailure { + Logger.error("Failed to set contact for payment '$forPaymentId'", it, context = TAG) + } + } + + private suspend fun findActivityForPaymentId(forPaymentId: String, syncLdkPayments: Boolean): Activity? { + val activity = getActivityByPaymentId(forPaymentId) + if (activity != null) return activity + if (!syncLdkPayments) return null + + syncActivities().getOrThrow() + return getActivityByPaymentId(forPaymentId) + } + + private suspend fun getActivityByPaymentId(forPaymentId: String): Activity? = + coreService.activity.getActivity(forPaymentId) + ?: getOnchainActivityByTxId(forPaymentId)?.let { Activity.Onchain(it) } + + private fun Activity.contact(): String? = when (this) { + is Activity.Lightning -> v1.contact + is Activity.Onchain -> v1.contact + } + + private fun Activity.withContact(normalizedKey: String, updatedAt: ULong): Activity = when (this) { + is Activity.Lightning -> Activity.Lightning(v1.copy(contact = normalizedKey, updatedAt = updatedAt)) + is Activity.Onchain -> Activity.Onchain(v1.copy(contact = normalizedKey, updatedAt = updatedAt)) + } + suspend fun getClosedChannels( sortDirection: SortDirection = SortDirection.ASC, ): Result> = withContext(bgDispatcher) { @@ -515,6 +591,7 @@ class ActivityRepo @Inject constructor( message = "", timestamp = now, preimage = null, + contact = null, createdAt = now, updatedAt = null, seenAt = null, diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 6cf2104c27..6614fe0e67 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -3,6 +3,7 @@ package to.bitkit.repositories import android.graphics.Bitmap import android.graphics.BitmapFactory import coil3.ImageLoader +import com.synonym.paykit.FfiPaymentEntry import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.post @@ -46,6 +47,7 @@ import kotlin.math.min enum class PubkyAuthState { Idle, Authenticating, Authenticated } sealed class PubkyContactError(message: String) : AppError(message) { + data object AlreadyExists : PubkyContactError("Contact already exists") data object CannotAddSelf : PubkyContactError("Cannot add your own pubky as a contact") data object InvalidFormat : PubkyContactError("Invalid pubky key format") } @@ -280,6 +282,34 @@ class PubkyRepo @Inject constructor( // endregion + // region Payment endpoints + + suspend fun getPaymentList(publicKey: String): Result> = withContext(ioDispatcher) { + runCatching { + pubkyService.getPaymentList(publicKey.ensurePubkyPrefix()) + } + } + + suspend fun setPaymentEndpoint(methodId: String, endpointData: String): Result = withContext(ioDispatcher) { + runCatching { + pubkyService.setPaymentEndpoint(methodId, endpointData) + } + } + + suspend fun removePaymentEndpoint(methodId: String): Result = withContext(ioDispatcher) { + runCatching { + pubkyService.removePaymentEndpoint(methodId) + } + } + + suspend fun currentPublicKey(): Result = withContext(ioDispatcher) { + runCatching { + pubkyService.currentPublicKey()?.ensurePubkyPrefix() + } + } + + // endregion + // region Profile loading suspend fun loadProfile() { @@ -918,11 +948,17 @@ class PubkyRepo @Inject constructor( private fun requireAddableContactPublicKey(publicKey: String): String { val prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) - ?: throw PubkyContactError.InvalidFormat - if (_publicKey.value == prefixedKey) { - throw PubkyContactError.CannotAddSelf + contactValidationError(prefixedKey)?.let { throw it } + return checkNotNull(prefixedKey) { "Normalized pubky key is required" } + } + + private fun contactValidationError(prefixedKey: String?): PubkyContactError? { + if (prefixedKey == null) return PubkyContactError.InvalidFormat + if (_publicKey.value == prefixedKey) return PubkyContactError.CannotAddSelf + if (_contacts.value.any { PubkyPublicKeyFormat.matches(it.publicKey, prefixedKey) }) { + return PubkyContactError.AlreadyExists } - return prefixedKey + return null } private fun String.ensurePubkyPrefix(): String = diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt new file mode 100644 index 0000000000..d99858d654 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -0,0 +1,283 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.Scanner +import com.synonym.bitkitcore.validateBitcoinAddress +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import to.bitkit.di.IoDispatcher +import to.bitkit.env.Env +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.toLdkNetwork +import to.bitkit.services.CoreService +import to.bitkit.utils.AppError +import to.bitkit.utils.NetworkValidationHelper +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +sealed class PublicPaykitError(message: String) : AppError(message) { + data object InvalidPayload : PublicPaykitError("Invalid Paykit payment endpoint payload") + data object NoSupportedEndpoint : PublicPaykitError("No supported public payment endpoint is available") + data object SessionNotActive : PublicPaykitError("No active Paykit session") + data object WalletNotReady : PublicPaykitError("Wallet is not ready to publish Paykit endpoints") +} + +sealed interface PublicPaykitPaymentResult { + data class Opened(val paymentRequest: String) : PublicPaykitPaymentResult + data object NoEndpoint : PublicPaykitPaymentResult + data object NotOpened : PublicPaykitPaymentResult +} + +@Singleton +class PublicPaykitRepo @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val pubkyRepo: PubkyRepo, + private val walletRepo: WalletRepo, + private val lightningRepo: LightningRepo, + private val coreService: CoreService, +) { + companion object { + private val methodIdPattern = Regex("^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$") + private val json = Json { ignoreUnknownKeys = true } + + private val payablePreferenceOrder = listOf( + MethodId.Bolt11, + MethodId.Lnurl, + MethodId.P2tr, + MethodId.P2wpkh, + MethodId.P2sh, + MethodId.P2pkh, + ) + + private val publishableMethodIds = listOf( + MethodId.Bolt11, + MethodId.P2tr, + MethodId.P2wpkh, + MethodId.P2sh, + MethodId.P2pkh, + ) + + fun parseEndpoint(methodId: String, endpointData: String): Endpoint? { + if (!methodIdPattern.matches(methodId)) return null + + val knownMethodId = MethodId.fromRawValue(methodId) ?: return null + val payload = runCatching { + json.decodeFromString(endpointData) + }.getOrNull() ?: return null + val value = payload.value.trim() + if (value.isEmpty()) return null + + return Endpoint( + methodId = knownMethodId, + value = value, + min = payload.min, + max = payload.max, + rawPayload = endpointData, + ) + } + + fun serializePayload(value: String): String { + val trimmedValue = value.trim() + if (trimmedValue.isEmpty()) throw PublicPaykitError.InvalidPayload + return json.encodeToString(PaymentEndpointPayload(value = trimmedValue)) + } + + fun paymentRequest(endpoints: List): String { + val sortedEndpoints = endpoints.sortedBy { payablePreferenceOrder.indexOf(it.methodId) } + val lightning = sortedEndpoints.firstOrNull { it.methodId == MethodId.Bolt11 } + val onchain = sortedEndpoints.firstOrNull { it.methodId.isOnchain } + + if (lightning != null && onchain != null) { + return "bitcoin:${onchain.value}?lightning=${lightning.value}" + } + + return sortedEndpoints.firstOrNull()?.paymentRequest.orEmpty() + } + + fun onchainMethodId(address: String): MethodId { + val normalizedAddress = address.lowercase(Locale.US) + return when { + normalizedAddress.startsWith("bc1p") || + normalizedAddress.startsWith("tb1p") || + normalizedAddress.startsWith("bcrt1p") -> + MethodId.P2tr + normalizedAddress.startsWith("bc1q") || + normalizedAddress.startsWith("tb1q") || + normalizedAddress.startsWith("bcrt1q") -> + MethodId.P2wpkh + normalizedAddress.startsWith("3") || normalizedAddress.startsWith("2") -> MethodId.P2sh + else -> MethodId.P2pkh + } + } + } + + suspend fun beginPayment(publicKey: String): Result = withContext(ioDispatcher) { + runCatching { + val endpoints = fetchPublicEndpoints(publicKey).getOrThrow() + if (endpoints.isEmpty()) return@runCatching PublicPaykitPaymentResult.NoEndpoint + + val payable = endpoints.filter { isPayable(it) } + if (payable.isEmpty()) return@runCatching PublicPaykitPaymentResult.NotOpened + + PublicPaykitPaymentResult.Opened(paymentRequest(payable)) + } + } + + suspend fun hasPayablePublicEndpoint(publicKey: String): Result = withContext(ioDispatcher) { + runCatching { + fetchPublicEndpoints(publicKey).getOrThrow().any { isPayable(it) } + } + } + + suspend fun syncPublishedEndpoints(publish: Boolean): Result = withContext(ioDispatcher) { + runCatching { + if (!publish) { + removePublishedEndpoints() + return@runCatching + } + + val desired = buildWalletEndpoints(refresh = true) + applyPublishedEndpoints(desired) + } + } + + suspend fun syncCurrentPublishedEndpoints(): Result = withContext(ioDispatcher) { + runCatching { + val desired = buildWalletEndpoints(refresh = false) + applyPublishedEndpoints(desired) + } + } + + private suspend fun fetchPublicEndpoints(publicKey: String): Result> = withContext(ioDispatcher) { + runCatching { + val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey + pubkyRepo.getPaymentList(normalizedKey).getOrThrow() + .mapNotNull { parseEndpoint(it.methodId, it.endpointData) } + .associateBy { it.methodId } + .values + .sortedBy { endpoint -> payablePreferenceOrder.indexOf(endpoint.methodId) } + } + } + + private suspend fun removePublishedEndpoints() { + val currentMethodIds = currentPublishedMethodIds() + publishableMethodIds + .filter { it.rawValue in currentMethodIds } + .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } + } + + private suspend fun applyPublishedEndpoints(desiredEndpoints: List) { + val desiredMethodIds = desiredEndpoints.map { it.methodId.rawValue }.toSet() + + desiredEndpoints.forEach { + pubkyRepo.setPaymentEndpoint(it.methodId.rawValue, it.rawPayload).getOrThrow() + } + + val publishedMethodIds = currentPublishedMethodIds() + publishableMethodIds + .filter { it.rawValue in publishedMethodIds && it.rawValue !in desiredMethodIds } + .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } + } + + private suspend fun currentPublishedMethodIds(): Set { + val currentPublicKey = pubkyRepo.publicKey.value + ?: pubkyRepo.currentPublicKey().getOrThrow() + if (currentPublicKey == null) throw PublicPaykitError.SessionNotActive + + return pubkyRepo.getPaymentList(currentPublicKey).getOrThrow() + .map { it.methodId } + .toSet() + } + + private suspend fun buildWalletEndpoints(refresh: Boolean): List { + if (refresh) { + lightningRepo.executeWhenNodeRunning( + operationName = "sync public Paykit endpoints", + ) { + walletRepo.refreshBip21() + }.getOrThrow() + } + + val state = walletRepo.walletState.value + val endpoints = mutableListOf() + + if (state.bolt11.isNotBlank()) { + endpoints += Endpoint( + methodId = MethodId.Bolt11, + value = state.bolt11, + rawPayload = serializePayload(state.bolt11), + ) + } + + val onchainAddress = state.onchainAddress + if (onchainAddress.isNotBlank()) { + val methodId = onchainMethodId(onchainAddress) + endpoints += Endpoint( + methodId = methodId, + value = onchainAddress, + rawPayload = serializePayload(onchainAddress), + ) + } + + if (endpoints.isEmpty()) throw PublicPaykitError.NoSupportedEndpoint + + return endpoints + } + + private suspend fun isPayable(endpoint: Endpoint): Boolean = runCatching { + when (endpoint.methodId) { + MethodId.Bolt11 -> { + val scan = coreService.decode(endpoint.paymentRequest) as? Scanner.Lightning ?: return@runCatching false + !scan.invoice.isExpired && + !NetworkValidationHelper.isNetworkMismatch(scan.invoice.networkType.toLdkNetwork(), Env.network) + } + MethodId.Lnurl -> coreService.decode(endpoint.paymentRequest) is Scanner.LnurlPay + MethodId.P2tr, + MethodId.P2wpkh, + MethodId.P2sh, + MethodId.P2pkh, + -> { + val scan = coreService.decode(endpoint.paymentRequest) as? Scanner.OnChain ?: return@runCatching false + val address = validateBitcoinAddress(scan.invoice.address) + onchainMethodId(scan.invoice.address) == endpoint.methodId && + !NetworkValidationHelper.isNetworkMismatch(address.network.toLdkNetwork(), Env.network) + } + } + }.getOrDefault(false) +} + +data class Endpoint( + val methodId: MethodId, + val value: String, + val min: String? = null, + val max: String? = null, + val rawPayload: String, +) { + val paymentRequest: String + get() = value +} + +enum class MethodId(val rawValue: String, val isOnchain: Boolean = false) { + Bolt11("btc-lightning-bolt11"), + Lnurl("btc-lightning-lnurl"), + P2tr("btc-bitcoin-p2tr", isOnchain = true), + P2wpkh("btc-bitcoin-p2wpkh", isOnchain = true), + P2sh("btc-bitcoin-p2sh", isOnchain = true), + P2pkh("btc-bitcoin-p2pkh", isOnchain = true), + ; + + companion object { + fun fromRawValue(value: String): MethodId? = entries.firstOrNull { it.rawValue == value } + } +} + +@Serializable +private data class PaymentEndpointPayload( + val value: String, + val min: String? = null, + val max: String? = null, +) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 568856ae13..338a6e1ce2 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -1014,6 +1014,7 @@ class MigrationService @Inject constructor( message = item.message ?: "", timestamp = timestampSecs, preimage = item.preimage, + contact = null, createdAt = timestampSecs, updatedAt = timestampSecs, seenAt = timestampSecs, @@ -1998,6 +1999,7 @@ class MigrationService @Inject constructor( confirmTimestamp = item.confirmTimestamp?.let { (it / MS_PER_SEC).toULong() }, channelId = item.channelId, transferTxId = item.transferTxId, + contact = null, doesExist = item.exists ?: true, createdAt = activityTimestamp, updatedAt = activityTimestamp, diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 120e304698..9a6a837160 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -18,12 +18,16 @@ import com.synonym.bitkitcore.pubkySessionPut import com.synonym.bitkitcore.pubkySignIn import com.synonym.bitkitcore.pubkySignUp import com.synonym.bitkitcore.startPubkyAuth +import com.synonym.paykit.FfiPaymentEntry import com.synonym.paykit.paykitExportSession import com.synonym.paykit.paykitForceSignOut import com.synonym.paykit.paykitGetCurrentPublicKey +import com.synonym.paykit.paykitGetPaymentList import com.synonym.paykit.paykitImportSession import com.synonym.paykit.paykitInitialize import com.synonym.paykit.paykitIsAuthenticated +import com.synonym.paykit.paykitRemovePaymentEndpoint +import com.synonym.paykit.paykitSetPaymentEndpoint import com.synonym.paykit.paykitSignOut import kotlinx.coroutines.CompletableDeferred import to.bitkit.async.ServiceQueue @@ -76,6 +80,25 @@ class PubkyService @Inject constructor() { // endregion + // region Payment endpoints + + suspend fun getPaymentList(publicKey: String): List = ServiceQueue.CORE.background { + isSetup.await() + paykitGetPaymentList(publicKey) + } + + suspend fun setPaymentEndpoint(methodId: String, endpointData: String) = ServiceQueue.CORE.background { + isSetup.await() + paykitSetPaymentEndpoint(methodId, endpointData) + } + + suspend fun removePaymentEndpoint(methodId: String) = ServiceQueue.CORE.background { + isSetup.await() + paykitRemovePaymentEndpoint(methodId) + } + + // endregion + // region Key derivation suspend fun mnemonicToSeed(mnemonic: String, passphrase: String?): ByteArray = diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index bae2bab203..e51004897c 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -61,6 +61,8 @@ import to.bitkit.ui.onboarding.WalletRestoreSuccessView import to.bitkit.ui.screens.CriticalUpdateScreen import to.bitkit.ui.screens.contacts.AddContactScreen import to.bitkit.ui.screens.contacts.AddContactViewModel +import to.bitkit.ui.screens.contacts.ContactActivityScreen +import to.bitkit.ui.screens.contacts.ContactActivityViewModel import to.bitkit.ui.screens.contacts.ContactDetailScreen import to.bitkit.ui.screens.contacts.ContactDetailViewModel import to.bitkit.ui.screens.contacts.ContactImportOverviewScreen @@ -78,6 +80,7 @@ import to.bitkit.ui.screens.profile.CreateProfileViewModel import to.bitkit.ui.screens.profile.EditProfileScreen import to.bitkit.ui.screens.profile.EditProfileViewModel import to.bitkit.ui.screens.profile.PayContactsScreen +import to.bitkit.ui.screens.profile.PayContactsViewModel import to.bitkit.ui.screens.profile.ProfileIntroScreen import to.bitkit.ui.screens.profile.ProfileScreen import to.bitkit.ui.screens.profile.ProfileViewModel @@ -975,15 +978,30 @@ private fun NavGraphBuilder.contacts( ContactDetailScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, + onPayContact = { paymentRequest, publicKey -> + appViewModel.openContactPayment(paymentRequest, publicKey) + }, + onActivityClick = { navController.navigateTo(Routes.ContactActivity(it)) }, onEditContact = { navController.navigateTo(Routes.EditContact(it)) }, ) } + composableWithDefaultTransitions { + val viewModel: ContactActivityViewModel = hiltViewModel() + ContactActivityScreen( + viewModel = viewModel, + onBackClick = { navController.popBackStack() }, + onActivityItemClick = { navController.navigateToActivityItem(it) }, + ) + } composableWithDefaultTransitions { val viewModel: AddContactViewModel = hiltViewModel() AddContactScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, onContactSaved = { navController.popBackStack() }, + onPayContact = { paymentRequest, publicKey -> + appViewModel.openContactPayment(paymentRequest, publicKey) + }, ) } composableWithDefaultTransitions { @@ -1082,7 +1100,9 @@ private fun NavGraphBuilder.profile( ) } composableWithDefaultTransitions { + val viewModel: PayContactsViewModel = hiltViewModel() PayContactsScreen( + viewModel = viewModel, onContinue = { navController.navigateTo(Routes.Profile) { popUpTo(Routes.Home) } }, @@ -1908,6 +1928,9 @@ sealed interface Routes { @Serializable data class ContactDetail(val publicKey: String) : Routes + @Serializable + data class ContactActivity(val publicKey: String) : Routes + @Serializable data object Profile : Routes diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt index 5ef753e410..c420a1f1ba 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.ImmutableList import to.bitkit.R import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.getClipboardText @@ -81,6 +82,7 @@ import to.bitkit.ui.utils.withAccent @Composable fun AddContactSheet( currentPublicKey: String?, + contacts: ImmutableList, onDismiss: () -> Unit, onSubmit: (publicKey: String) -> Unit, onScanQr: () -> Unit, @@ -90,9 +92,11 @@ fun AddContactSheet( val validationResult = resolveAddContactValidation( input = publicKeyInput, ownPublicKey = currentPublicKey, + contacts = contacts, ) val validationMessage = when (validationResult) { AddContactValidationResult.Empty -> null + AddContactValidationResult.ExistingContact -> context.getString(R.string.contacts__add_error_existing) AddContactValidationResult.InvalidKey -> context.getString(R.string.contacts__add_error_invalid_key) AddContactValidationResult.OwnKey -> context.getString(R.string.contacts__add_error_self) is AddContactValidationResult.Valid -> null @@ -211,6 +215,7 @@ fun AddContactScreen( viewModel: AddContactViewModel, onBackClick: () -> Unit, onContactSaved: () -> Unit, + onPayContact: (String, String) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -218,6 +223,7 @@ fun AddContactScreen( viewModel.effects.collect { when (it) { AddContactEffect.ContactSaved -> onContactSaved() + is AddContactEffect.OpenPayment -> onPayContact(it.paymentRequest, it.publicKey) } } } @@ -225,6 +231,7 @@ fun AddContactScreen( Content( uiState = uiState, onBackClick = onBackClick, + onPay = { viewModel.payContact() }, onSave = { viewModel.saveContact() }, onRetry = { viewModel.fetchProfile(uiState.publicKeyInput) }, ) @@ -234,6 +241,7 @@ fun AddContactScreen( private fun Content( uiState: AddContactUiState, onBackClick: () -> Unit, + onPay: () -> Unit, onSave: () -> Unit, onRetry: () -> Unit, ) { @@ -255,6 +263,8 @@ private fun Content( uiState.fetchedProfile != null -> LoadedContent( profile = uiState.fetchedProfile, isLoading = uiState.isLoading, + hasPublicPaymentEndpoint = uiState.hasPublicPaymentEndpoint, + onPay = onPay, onDiscard = onBackClick, onSave = onSave, ) @@ -427,6 +437,8 @@ private fun ErrorContent( private fun LoadedContent( profile: PubkyProfile, isLoading: Boolean, + hasPublicPaymentEndpoint: Boolean, + onPay: () -> Unit, onDiscard: () -> Unit, onSave: () -> Unit, ) { @@ -445,6 +457,15 @@ private fun LoadedContent( imageUrl = profile.imageUrl, ) + if (hasPublicPaymentEndpoint) { + VerticalSpacer(24.dp) + SecondaryButton( + text = stringResource(R.string.wallet__send), + onClick = onPay, + modifier = Modifier.testTag("AddContactPay") + ) + } + FillHeight() BodyS( @@ -507,6 +528,7 @@ private fun LoadingPreview() { isLoading = true, ), onBackClick = {}, + onPay = {}, onSave = {}, onRetry = {}, ) @@ -530,6 +552,7 @@ private fun LoadedPreview() { ), ), onBackClick = {}, + onPay = {}, onSave = {}, onRetry = {}, ) @@ -546,6 +569,7 @@ private fun ErrorPreview() { error = "Could not retrieve contact info. Please check the public key and try again.", ), onBackClick = {}, + onPay = {}, onSave = {}, onRetry = {}, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index 2e0ef85e32..14232a0e01 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -19,6 +19,8 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitPaymentResult +import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -27,6 +29,7 @@ import javax.inject.Inject class AddContactViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, + private val publicPaykitRepo: PublicPaykitRepo, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -54,16 +57,26 @@ class AddContactViewModel @Inject constructor( fun fetchProfile(publicKey: String) { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null, publicKeyInput = publicKey) } + _uiState.update { + it.copy( + isLoading = true, + error = null, + publicKeyInput = publicKey, + hasPublicPaymentEndpoint = false, + ) + } pubkyRepo.fetchContactProfile(publicKey) .onSuccess { profile -> _uiState.update { it.copy(fetchedProfile = profile, isLoading = false) } + loadPaymentEndpoint(profile.publicKey) } .onFailure { error -> _uiState.update { state -> state.copy( isLoading = false, error = when (error) { + PubkyContactError.AlreadyExists -> + context.getString(R.string.contacts__add_error_existing) PubkyContactError.CannotAddSelf -> context.getString(R.string.contacts__add_error_self) PubkyContactError.InvalidFormat -> @@ -77,6 +90,47 @@ class AddContactViewModel @Inject constructor( } } + private fun loadPaymentEndpoint(publicKey: String) { + viewModelScope.launch { + publicPaykitRepo.hasPayablePublicEndpoint(publicKey) + .onSuccess { hasEndpoint -> + _uiState.update { it.copy(hasPublicPaymentEndpoint = hasEndpoint) } + } + .onFailure { + Logger.warn("Failed to load public Paykit endpoint for '$publicKey'", it, context = TAG) + } + } + } + + fun payContact() { + val profile = _uiState.value.fetchedProfile ?: return + viewModelScope.launch { + publicPaykitRepo.beginPayment(profile.publicKey) + .onSuccess { result -> + when (result) { + is PublicPaykitPaymentResult.Opened -> + _effects.emit(AddContactEffect.OpenPayment(result.paymentRequest, profile.publicKey)) + PublicPaykitPaymentResult.NoEndpoint -> + showPayError(R.string.slashtags__error_pay_empty_msg) + PublicPaykitPaymentResult.NotOpened -> + showPayError(R.string.slashtags__error_pay_not_opened_msg) + } + } + .onFailure { + Logger.warn("Failed to begin public Paykit payment for '${profile.publicKey}'", it, context = TAG) + showPayError(R.string.slashtags__error_pay_not_opened_msg) + } + } + } + + private suspend fun showPayError(messageRes: Int) { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.slashtags__error_pay_title), + description = context.getString(messageRes), + ) + } + fun saveContact() { val profile = _uiState.value.fetchedProfile ?: return viewModelScope.launch { @@ -108,9 +162,11 @@ data class AddContactUiState( val publicKeyInput: String = "", val fetchedProfile: PubkyProfile? = null, val isLoading: Boolean = false, + val hasPublicPaymentEndpoint: Boolean = false, val error: String? = null, ) sealed interface AddContactEffect { data object ContactSaved : AddContactEffect + data class OpenPayment(val paymentRequest: String, val publicKey: String) : AddContactEffect } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt new file mode 100644 index 0000000000..1e51dfba3b --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityScreen.kt @@ -0,0 +1,190 @@ +package to.bitkit.ui.screens.contacts + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.synonym.bitkitcore.Activity +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import to.bitkit.R +import to.bitkit.ext.isSent +import to.bitkit.models.PubkyProfile +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.GradientCircularProgressIndicator +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.screens.wallets.activity.components.ActivityListGrouped +import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun ContactActivityScreen( + viewModel: ContactActivityViewModel, + onBackClick: () -> Unit, + onActivityItemClick: (String) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Content( + uiState = uiState, + onBackClick = onBackClick, + onRetryClick = { viewModel.loadActivities() }, + onActivityItemClick = onActivityItemClick, + ) +} + +@Composable +private fun Content( + uiState: ContactActivityUiState, + onBackClick: () -> Unit, + onRetryClick: () -> Unit, + onActivityItemClick: (String) -> Unit, +) { + ScreenColumn { + AppTopBar( + titleText = uiState.profile?.name ?: stringResource(R.string.wallet__activity), + onBackClick = onBackClick, + actions = { DrawerNavIcon() }, + ) + + Box(modifier = Modifier.fillMaxSize()) { + when { + uiState.isLoading && uiState.activities == null -> { + GradientCircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + uiState.error != null -> { + ErrorState( + message = uiState.error, + onRetryClick = onRetryClick, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 32.dp) + ) + } + + else -> { + ContactActivityList( + profile = uiState.profile, + activities = uiState.activities, + onActivityItemClick = onActivityItemClick, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("ContactActivityList") + ) + } + } + } + } +} + +@Composable +private fun ErrorState( + message: String, + onRetryClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.fillMaxWidth() + ) { + BodyM(text = message, color = Colors.White64) + VerticalSpacer(16.dp) + SecondaryButton( + text = stringResource(R.string.common__retry), + onClick = onRetryClick, + modifier = Modifier.testTag("ContactActivityRetry") + ) + } +} + +@Composable +private fun ContactActivityList( + profile: PubkyProfile?, + activities: ImmutableList?, + onActivityItemClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val name = profile?.name + ActivityListGrouped( + items = activities, + onActivityItemClick = onActivityItemClick, + onEmptyActivityRowClick = {}, + contentPadding = PaddingValues(top = 0.dp), + titleProvider = { activity -> + name?.let { + val titleRes = if (activity.isSent()) { + R.string.contacts__activity_sent_to + } else { + R.string.contacts__activity_received_from + } + stringResource(titleRes, it) + } + }, + modifier = modifier + ) +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = ContactActivityUiState( + profile = PubkyProfile.placeholder("pubky1"), + activities = previewActivityItems, + ), + onBackClick = {}, + onRetryClick = {}, + onActivityItemClick = {}, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewEmpty() { + AppThemeSurface { + Content( + uiState = ContactActivityUiState( + profile = PubkyProfile.placeholder("pubky1"), + activities = persistentListOf(), + ), + onBackClick = {}, + onRetryClick = {}, + onActivityItemClick = {}, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewError() { + AppThemeSurface { + Content( + uiState = ContactActivityUiState( + profile = PubkyProfile.placeholder("pubky1"), + error = "Failed to load activity", + ), + onBackClick = {}, + onRetryClick = {}, + onActivityItemClick = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt new file mode 100644 index 0000000000..d5e002e394 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt @@ -0,0 +1,116 @@ +package to.bitkit.ui.screens.contacts + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.Activity +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.PubkyRepo +import javax.inject.Inject + +@HiltViewModel +class ContactActivityViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val activityRepo: ActivityRepo, + private val pubkyRepo: PubkyRepo, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val publicKey: String = checkNotNull( + savedStateHandle["publicKey"], + ) { "publicKey not found in SavedStateHandle" } + + private val _uiState = MutableStateFlow(ContactActivityUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadContact() + loadActivities() + observeContactUpdates() + observeActivityUpdates() + } + + fun loadActivities() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + activityRepo.contactActivities(publicKey) + .onSuccess { activities -> + _uiState.update { + it.copy( + activities = activities.toImmutableList(), + isLoading = false, + error = null, + ) + } + } + .onFailure { + _uiState.update { + it.copy( + isLoading = false, + error = context.getString(R.string.wallet__activity_error_load_failed), + ) + } + } + } + } + + private fun loadContact() { + viewModelScope.launch { + pubkyRepo.contacts.value.matchingContact()?.let { cached -> + _uiState.update { it.copy(profile = cached) } + return@launch + } + pubkyRepo.fetchContactProfile(publicKey) + .onSuccess { profile -> _uiState.update { it.copy(profile = profile) } } + .onFailure { + _uiState.update { it.copy(profile = PubkyProfile.placeholder(publicKey)) } + } + } + } + + private fun observeContactUpdates() { + viewModelScope.launch { + pubkyRepo.contacts.collect { contacts -> + contacts.matchingContact()?.let { profile -> + _uiState.update { it.copy(profile = profile) } + } + } + } + } + + private fun observeActivityUpdates() { + viewModelScope.launch { + activityRepo.activitiesChanged.drop(1).collect { + loadActivities() + } + } + } + + private fun List.matchingContact(): PubkyProfile? { + val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey + return firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, normalizedKey) } + } +} + +@Stable +data class ContactActivityUiState( + val profile: PubkyProfile? = null, + val activities: ImmutableList? = null, + val isLoading: Boolean = false, + val error: String? = null, +) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index 05aee93703..437be42baf 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -51,6 +51,8 @@ import to.bitkit.ui.theme.Colors fun ContactDetailScreen( viewModel: ContactDetailViewModel, onBackClick: () -> Unit, + onPayContact: (String, String) -> Unit, + onActivityClick: (String) -> Unit, onEditContact: (String) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -60,6 +62,7 @@ fun ContactDetailScreen( viewModel.effects.collect { when (it) { ContactDetailEffect.DeleteSuccess -> onBackClick() + is ContactDetailEffect.OpenPayment -> onPayContact(it.paymentRequest, it.publicKey) } } } @@ -69,6 +72,8 @@ fun ContactDetailScreen( onBackClick = onBackClick, onClickEdit = { uiState.profile?.publicKey?.let { onEditContact(it) } }, onClickCopy = { viewModel.copyPublicKey() }, + onClickPay = { viewModel.payContact() }, + onClickActivity = { uiState.profile?.publicKey?.let { onActivityClick(it) } }, onClickShare = { uiState.profile?.publicKey?.let { shareText(context, it) } }, onClickDelete = { viewModel.showDeleteConfirmation() }, onClickRetry = { viewModel.loadContact() }, @@ -87,6 +92,8 @@ private fun Content( onBackClick: () -> Unit, onClickEdit: () -> Unit, onClickCopy: () -> Unit, + onClickPay: () -> Unit, + onClickActivity: () -> Unit, onClickShare: () -> Unit, onClickDelete: () -> Unit, onClickRetry: () -> Unit, @@ -111,8 +118,11 @@ private fun Content( currentProfile != null -> ContactBody( profile = currentProfile, tags = uiState.tags, + hasPublicPaymentEndpoint = uiState.hasPublicPaymentEndpoint, onClickEdit = onClickEdit, onClickCopy = onClickCopy, + onClickPay = onClickPay, + onClickActivity = onClickActivity, onClickShare = onClickShare, onClickDelete = onClickDelete, onAddTag = onAddTag, @@ -145,8 +155,11 @@ private fun Content( private fun ContactBody( profile: PubkyProfile, tags: ImmutableList, + hasPublicPaymentEndpoint: Boolean, onClickEdit: () -> Unit, onClickCopy: () -> Unit, + onClickPay: () -> Unit, + onClickActivity: () -> Unit, onClickShare: () -> Unit, onClickDelete: () -> Unit, onAddTag: () -> Unit, @@ -172,8 +185,9 @@ private fun ContactBody( VerticalSpacer(24.dp) - Row( + FlowRow( horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { ActionButton( @@ -181,6 +195,18 @@ private fun ContactBody( iconRes = R.drawable.ic_copy, modifier = Modifier.testTag("ContactCopy") ) + if (hasPublicPaymentEndpoint) { + ActionButton( + onClick = onClickPay, + iconRes = R.drawable.ic_coins, + modifier = Modifier.testTag("ContactPay") + ) + } + ActionButton( + onClick = onClickActivity, + iconRes = R.drawable.ic_list_dashes, + modifier = Modifier.testTag("ContactActivity") + ) ActionButton( onClick = onClickShare, iconRes = R.drawable.ic_share, @@ -291,6 +317,8 @@ private fun Preview() { onBackClick = {}, onClickEdit = {}, onClickCopy = {}, + onClickPay = {}, + onClickActivity = {}, onClickShare = {}, onClickDelete = {}, onClickRetry = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 67ce312427..a03d7a1b36 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -23,6 +23,8 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitPaymentResult +import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -31,6 +33,7 @@ import javax.inject.Inject class ContactDetailViewModel @Inject constructor( @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, + private val publicPaykitRepo: PublicPaykitRepo, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -50,6 +53,7 @@ class ContactDetailViewModel @Inject constructor( init { loadContact() + loadPaymentEndpoint() observeContactUpdates() } @@ -88,6 +92,46 @@ class ContactDetailViewModel @Inject constructor( } } + private fun loadPaymentEndpoint() { + viewModelScope.launch { + publicPaykitRepo.hasPayablePublicEndpoint(publicKey) + .onSuccess { hasEndpoint -> + _uiState.update { it.copy(hasPublicPaymentEndpoint = hasEndpoint) } + } + .onFailure { + Logger.warn("Failed to load public Paykit endpoint for '$publicKey'", it, context = TAG) + } + } + } + + fun payContact() { + viewModelScope.launch { + publicPaykitRepo.beginPayment(publicKey) + .onSuccess { result -> + when (result) { + is PublicPaykitPaymentResult.Opened -> + _effects.emit(ContactDetailEffect.OpenPayment(result.paymentRequest, publicKey)) + PublicPaykitPaymentResult.NoEndpoint -> + showPayError(R.string.slashtags__error_pay_empty_msg) + PublicPaykitPaymentResult.NotOpened -> + showPayError(R.string.slashtags__error_pay_not_opened_msg) + } + } + .onFailure { + Logger.warn("Failed to begin public Paykit payment for '$publicKey'", it, context = TAG) + showPayError(R.string.slashtags__error_pay_not_opened_msg) + } + } + } + + private suspend fun showPayError(messageRes: Int) { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.slashtags__error_pay_title), + description = context.getString(messageRes), + ) + } + private fun observeContactUpdates() { viewModelScope.launch { pubkyRepo.contacts.collect { contacts -> @@ -180,10 +224,12 @@ data class ContactDetailUiState( val profile: PubkyProfile? = null, val tags: ImmutableList = persistentListOf(), val isLoading: Boolean = false, + val hasPublicPaymentEndpoint: Boolean = false, val showAddTagSheet: Boolean = false, val showDeleteDialog: Boolean = false, ) sealed interface ContactDetailEffect { data object DeleteSuccess : ContactDetailEffect + data class OpenPayment(val paymentRequest: String, val publicKey: String) : ContactDetailEffect } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportFlow.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportFlow.kt index bcac9b85a4..06fdce7f20 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportFlow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportFlow.kt @@ -11,6 +11,7 @@ internal fun hasPendingImport(profile: PubkyProfile?, contacts: List = emptyList(), ): AddContactValidationResult { val trimmedInput = input.trim() @@ -33,6 +35,10 @@ internal fun resolveAddContactValidation( val normalizedKey = PubkyPublicKeyFormat.normalized(trimmedInput) ?: return AddContactValidationResult.InvalidKey + if (contacts.any { PubkyPublicKeyFormat.matches(it.publicKey, normalizedKey) }) { + return AddContactValidationResult.ExistingContact + } + return AddContactValidationResult.Valid(normalizedKey = normalizedKey) } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt index 5f272d99c1..e4ebffd099 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsScreen.kt @@ -137,6 +137,7 @@ private fun Content( if (showAddContactSheet) { AddContactSheet( currentPublicKey = uiState.myProfile?.publicKey, + contacts = uiState.contacts, onDismiss = { showAddContactSheet = false }, onSubmit = { publicKey -> showAddContactSheet = false diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt index af4c58d26d..5c85e90b12 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt @@ -8,10 +8,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -19,6 +17,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display @@ -34,22 +33,35 @@ import to.bitkit.ui.utils.withAccent @Composable fun PayContactsScreen( + viewModel: PayContactsViewModel, onContinue: () -> Unit, onBackClick: () -> Unit, ) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.effects.collect { + when (it) { + PayContactsEffect.Continue -> onContinue() + } + } + } + Content( - onContinue = onContinue, + uiState = uiState, + onPaymentSharingChange = { viewModel.setPaymentSharingEnabled(it) }, + onContinue = { viewModel.continueToProfile() }, onBackClick = onBackClick, ) } @Composable private fun Content( + uiState: PayContactsUiState, + onPaymentSharingChange: (Boolean) -> Unit, onContinue: () -> Unit, onBackClick: () -> Unit, ) { - var isPaymentSharingEnabled by remember { mutableStateOf(true) } - ScreenColumn { AppTopBar( titleText = stringResource(R.string.profile__pay_contacts_title), @@ -91,8 +103,8 @@ private fun Content( ) HorizontalSpacer(16.dp) Switch( - checked = isPaymentSharingEnabled, - onCheckedChange = { isPaymentSharingEnabled = it }, + checked = uiState.isPaymentSharingEnabled, + onCheckedChange = onPaymentSharingChange, colors = SwitchDefaults.colors( checkedThumbColor = Colors.White, checkedTrackColor = Colors.PubkyGreen, @@ -101,7 +113,7 @@ private fun Content( uncheckedTrackColor = Colors.Gray4, uncheckedBorderColor = Colors.Gray4, ), - modifier = Modifier.testTag("PayContactsToggle"), + modifier = Modifier.testTag("PayContactsToggle") ) } @@ -109,7 +121,8 @@ private fun Content( PrimaryButton( text = stringResource(R.string.common__continue), onClick = onContinue, - modifier = Modifier.testTag("PayContactsContinue"), + enabled = !uiState.isLoading, + modifier = Modifier.testTag("PayContactsContinue") ) VerticalSpacer(16.dp) } @@ -121,6 +134,8 @@ private fun Content( private fun Preview() { AppThemeSurface { Content( + uiState = PayContactsUiState(), + onPaymentSharingChange = {}, onContinue = {}, onBackClick = {}, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt new file mode 100644 index 0000000000..36e5237579 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt @@ -0,0 +1,102 @@ +package to.bitkit.ui.screens.profile + +import android.content.Context +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.data.SettingsStore +import to.bitkit.models.Toast +import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Logger +import javax.inject.Inject + +@HiltViewModel +class PayContactsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val settingsStore: SettingsStore, + private val publicPaykitRepo: PublicPaykitRepo, +) : ViewModel() { + companion object { + private const val TAG = "PayContactsViewModel" + } + + private val _uiState = MutableStateFlow(PayContactsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + val effects = _effects.asSharedFlow() + + init { + viewModelScope.launch { + val settings = settingsStore.data.first() + _uiState.update { + it.copy( + isPaymentSharingEnabled = settings.sharesPublicPaykitEndpoints || + !settings.hasConfirmedPublicPaykitEndpoints, + ) + } + } + } + + fun setPaymentSharingEnabled(isEnabled: Boolean) { + _uiState.update { it.copy(isPaymentSharingEnabled = isEnabled) } + } + + fun continueToProfile() { + viewModelScope.launch { + val shouldPublish = _uiState.value.isPaymentSharingEnabled + _uiState.update { it.copy(isLoading = true) } + + publicPaykitRepo.syncPublishedEndpoints(shouldPublish) + .onSuccess { + settingsStore.update { + it.copy( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = shouldPublish, + ) + } + _uiState.update { it.copy(isLoading = false) } + _effects.emit(PayContactsEffect.Continue) + } + .onFailure { + val settings = settingsStore.data.first() + val persistedValue = settings.sharesPublicPaykitEndpoints || + !settings.hasConfirmedPublicPaykitEndpoints + Logger.error("Failed to sync public Paykit endpoints", it, context = TAG) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = it.message ?: context.getString(R.string.common__error_body), + ) + _uiState.update { + it.copy( + isLoading = false, + isPaymentSharingEnabled = persistedValue, + ) + } + } + } + } +} + +@Immutable +data class PayContactsUiState( + val isPaymentSharingEnabled: Boolean = true, + val isLoading: Boolean = false, +) + +sealed interface PayContactsEffect { + data object Continue : PayContactsEffect +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index 7ff7c1c4d1..bf6d7612d0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -45,6 +45,7 @@ fun ActivityListGrouped( showFooter: Boolean = false, onAllActivityButtonClick: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(top = 20.dp), + titleProvider: @Composable (Activity) -> String? = { null }, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -97,7 +98,12 @@ fun ActivityListGrouped( placementSpec = tween(durationMillis = 300) ) ) { - ActivityRow(item, onActivityItemClick, testTag = "Activity-$index") + ActivityRow( + item = item, + onClick = onActivityItemClick, + testTag = "Activity-$index", + title = titleProvider(item), + ) VerticalSpacer(16.dp) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 6d7e4e028b..527f7ab455 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -67,6 +67,7 @@ fun ActivityRow( item: Activity, onClick: (String) -> Unit, testTag: String, + title: String? = null, ) { val blocktankInfo by blocktankViewModel?.info?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) @@ -119,7 +120,8 @@ fun ActivityRow( isLightning = isLightning, status = status, isTransfer = isTransfer, - isCpfpChild = isCpfpChild + isCpfpChild = isCpfpChild, + title = title, ) val context = LocalContext.current val subtitleText = when (item) { @@ -178,7 +180,13 @@ private fun TransactionStatusText( status: PaymentState?, isTransfer: Boolean, isCpfpChild: Boolean = false, + title: String? = null, ) { + if (title != null) { + BodyMSB(text = title) + return + } + when { isTransfer -> BodyMSB(text = stringResource(R.string.wallet__activity_transfer)) isCpfpChild -> BodyMSB(text = stringResource(R.string.wallet__activity_boost_fee)) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 9b9431d65f..056be74adf 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -276,6 +276,7 @@ fun SendSheet( ) }, onPaymentPending = { paymentHash, amount -> + appViewModel.preserveContactPaymentContext(paymentHash) navController.navigateTo(SendRoute.Pending(paymentHash, amount)) { popUpTo(startDestination) { inclusive = true } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7c1ad3b4ec..817cc8aa73 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -45,6 +45,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -120,6 +121,7 @@ import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WidgetsRepo @@ -178,6 +180,7 @@ class AppViewModel @Inject constructor( private val migrationService: MigrationService, private val coreService: CoreService, private val pubkyRepo: PubkyRepo, + private val publicPaykitRepo: PublicPaykitRepo, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, private val notificationsSheet: NotificationsTimedSheet, @@ -231,7 +234,11 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() + private val processedPaymentsLock = Any() private val processedPayments = mutableSetOf() + private val contactPaymentContextLock = Any() + private var activeContactPaymentContext: ContactPaymentContext? = null + private val pendingContactPaymentContexts = mutableMapOf() private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { registerSheet(appUpdateSheet) registerSheet(backupSheet) @@ -324,6 +331,7 @@ class AppViewModel @Inject constructor( } } observeLdkNodeEvents() + observePublicPaykitEndpoints() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -360,6 +368,24 @@ class AppViewModel @Inject constructor( } } + private fun observePublicPaykitEndpoints() { + viewModelScope.launch { + walletRepo.walletState + .map { it.bolt11 to it.onchainAddress } + .distinctUntilChanged() + .collect { (bolt11, onchainAddress) -> + val shouldPublish = settingsStore.data.first().sharesPublicPaykitEndpoints + if (!shouldPublish) return@collect + if (bolt11.isBlank() && onchainAddress.isBlank()) return@collect + + publicPaykitRepo.syncCurrentPublishedEndpoints() + .onFailure { + Logger.warn("Failed to refresh public Paykit endpoints", it, context = TAG) + } + } + } + } + @Suppress("CyclomaticComplexMethod") private fun handleLdkEvent(event: Event) { if (!walletRepo.walletExists()) return @@ -1276,7 +1302,7 @@ class AppViewModel @Inject constructor( ) return } - launchScan(source = ScanSource.PASTE, data = data) + launchScan(source = ScanSource.PASTE, data = data, routePubkyKeys = true) } private fun onScanClick() { @@ -1296,6 +1322,21 @@ class AppViewModel @Inject constructor( ) } + fun openContactPayment(paymentRequest: String, publicKey: String) { + synchronized(contactPaymentContextLock) { + activeContactPaymentContext = ContactPaymentContext(publicKey) + } + onScanResult(paymentRequest) + } + + fun preserveContactPaymentContext(paymentHash: String) { + synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.let { + pendingContactPaymentContexts[paymentHash] = it + } + } + } + private suspend fun handleScan( result: String, routePubkyKeys: Boolean, @@ -1314,10 +1355,12 @@ class AppViewModel @Inject constructor( description = context.getString(R.string.other__scan__error__generic), testTag = "DuplicatedBip21Toast", ) + clearActiveContactPaymentContext() return@withContext } if (input.startsWith("$PUBKYAUTH_SCHEME://")) { + clearActiveContactPaymentContext() handlePubkyAuth(input) return@withContext } @@ -1330,6 +1373,7 @@ class AppViewModel @Inject constructor( ) if (route != null) { + clearActiveContactPaymentContext() mainScreenEffect(MainScreenEffect.Navigate(route)) return@withContext } @@ -1340,23 +1384,42 @@ class AppViewModel @Inject constructor( .onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) } .getOrNull() - when (scan) { - is Scanner.OnChain -> onScanOnchain(scan.invoice, input) - is Scanner.Lightning -> onScanLightning(scan.invoice, input) - is Scanner.LnurlPay -> onScanLnurlPay(scan.data) - is Scanner.LnurlWithdraw -> onScanLnurlWithdraw(scan.data) - is Scanner.LnurlAuth -> onScanLnurlAuth(scan.data) - is Scanner.LnurlChannel -> onScanLnurlChannel(scan.data) - is Scanner.NodeId -> onScanNodeId(scan) - is Scanner.Gift -> onScanGift(scan.code, scan.amount) - else -> { - Logger.warn("Unhandled scan data: $scan", context = TAG) - toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.other__qr_error_header), - description = context.getString(R.string.other__qr_error_text), - ) + handleDecodedScan(scan, input) + } + + @Suppress("CyclomaticComplexMethod") + private suspend fun handleDecodedScan(scan: Scanner?, input: String) = when (scan) { + is Scanner.OnChain -> onScanOnchain(scan.invoice, input) + is Scanner.Lightning -> onScanLightning(scan.invoice, input) + is Scanner.LnurlPay -> onScanLnurlPay(scan.data) + is Scanner.LnurlWithdraw -> handleNonPaymentScan { onScanLnurlWithdraw(scan.data) } + is Scanner.LnurlAuth -> handleNonPaymentScan { onScanLnurlAuth(scan.data) } + is Scanner.LnurlChannel -> handleNonPaymentScan { onScanLnurlChannel(scan.data) } + is Scanner.NodeId -> handleNonPaymentScan { onScanNodeId(scan) } + is Scanner.Gift -> handleNonPaymentScan { onScanGift(scan.code, scan.amount) } + else -> { + if (scan == null) { + Logger.warn("Failed to decode scan data", context = TAG) + } else { + Logger.warn("Received unhandled scan data '$scan'", context = TAG) } + toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.other__qr_error_header), + description = context.getString(R.string.other__qr_error_text), + ) + clearActiveContactPaymentContext() + } + } + + private suspend fun handleNonPaymentScan(action: suspend () -> Unit) { + clearActiveContactPaymentContext() + action() + } + + private fun clearActiveContactPaymentContext() { + synchronized(contactPaymentContextLock) { + activeContactPaymentContext = null } } @@ -1370,6 +1433,7 @@ class AppViewModel @Inject constructor( description = context.getString(R.string.wallet__error_invalid_bitcoin_address), testTag = "InvalidAddressToast", ) + clearActiveContactPaymentContext() return } @@ -1380,6 +1444,7 @@ class AppViewModel @Inject constructor( description = context.getString(R.string.other__scan__error__generic), testTag = "InvalidAddressToast", ) + clearActiveContactPaymentContext() return } val maxSendOnchain = walletRepo.balanceState.value.maxSendOnchainSats @@ -1427,6 +1492,7 @@ class AppViewModel @Inject constructor( description = context.getString(R.string.other__pay_insufficient_savings_description), testTag = "InsufficientSavingsToast", ) + clearActiveContactPaymentContext() return } @@ -1444,6 +1510,7 @@ class AppViewModel @Inject constructor( .replace("{amount}", formatMoneyValue(shortfall)), testTag = "InsufficientSavingsToast", ) + clearActiveContactPaymentContext() return } @@ -1470,6 +1537,7 @@ class AppViewModel @Inject constructor( description = context.getString(R.string.other__scan__error__expired), testTag = "ExpiredLightningToast", ) + clearActiveContactPaymentContext() return } @@ -1486,6 +1554,7 @@ class AppViewModel @Inject constructor( .replace("{amount}", formatMoneyValue(shortfall)), testTag = "InsufficientSpendingToast", ) + clearActiveContactPaymentContext() return } @@ -1530,6 +1599,7 @@ class AppViewModel @Inject constructor( title = context.getString(R.string.other__lnurl_pay_error), description = context.getString(R.string.other__lnurl_pay_error_no_capacity), ) + clearActiveContactPaymentContext() return } @@ -1915,6 +1985,7 @@ class AppViewModel @Inject constructor( if (it is PaymentPendingException) { Logger.info("Lightning payment pending", context = TAG) pendingPaymentRepo.track(it.paymentHash) + preserveContactPaymentContext(it.paymentHash) setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) return@onFailure } @@ -2332,6 +2403,7 @@ class AppViewModel @Inject constructor( else -> _currentSheet.update { null } } + clearActiveContactPaymentContext() } // endregion @@ -2491,16 +2563,38 @@ class AppViewModel @Inject constructor( fun onSendSuccess(details: NewTransactionSheetDetails) { details.paymentHashOrTxId?.let { - if (!processedPayments.add(it)) { - Logger.debug("Payment $it already processed, skipping duplicate", context = TAG) + val isNewPayment = synchronized(processedPaymentsLock) { + processedPayments.add(it) + } + if (!isNewPayment) { + Logger.debug("Skipped duplicate processed payment '$it'", context = TAG) return } + syncContactForActivity(it) } _successSendUiState.update { details } setSendEffect(SendEffect.PaymentSuccess) } + private fun syncContactForActivity(paymentHashOrTxId: String) { + val contactContext = synchronized(contactPaymentContextLock) { + val context = pendingContactPaymentContexts.remove(paymentHashOrTxId) + ?: activeContactPaymentContext + if (context != null) { + activeContactPaymentContext = null + } + context + } ?: return + + viewModelScope.launch { + activityRepo.setContact( + contactPublicKey = contactContext.publicKey, + forPaymentId = paymentHashOrTxId, + ) + } + } + fun handleDeeplinkIntent(intent: Intent) { intent.data?.let { uri -> Logger.debug("Received deeplink: $uri") @@ -2655,6 +2749,8 @@ sealed class SendFee(open val value: Long) { enum class SendMethod { ONCHAIN, LIGHTNING } +data class ContactPaymentContext(val publicKey: String) + sealed class SendEffect { data class PopBack(val route: SendRoute) : SendEffect() data object NavigateToAddress : SendEffect() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f00160f9af..65b5073814 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -69,11 +69,14 @@ Usable Yes Yes, Proceed + Received from %1$s + Sent to %1$s Add Contact saved Add Contact Add a new contact by scanning their QR or pasting their pubky below. Discard + This pubky is already in your contacts. Could not retrieve contact info. Please check the public key and try again. Invalid pubky key format. Please check and try again. You can\'t add your own pubky as a contact. @@ -879,6 +882,9 @@ Display Reset To Defaults Show Widgets + The contact you\'re trying to send to hasn\'t enabled payments. + No compatible payment endpoint is available. + Unable To Pay Contact Own your\n<accent>profile</accent> Set up your public profile and links, so your Bitkit contacts can reach you or pay you anytime, anywhere. Profile diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index a6249d3cdd..f148b16354 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -123,6 +123,7 @@ class ActivityRepoTest : BaseUnitTest() { sut = ActivityRepo( bgDispatcher = testDispatcher, + ioDispatcher = testDispatcher, coreService = coreService, lightningRepo = lightningRepo, blocktankRepo = blocktankRepo, diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt new file mode 100644 index 0000000000..85960fc8d8 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -0,0 +1,143 @@ +package to.bitkit.repositories + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PublicPaykitRepoTest { + @Test + fun `parseEndpoint accepts Paykit JSON payloads`() { + val endpoint = PublicPaykitRepo.parseEndpoint( + methodId = "btc-lightning-bolt11", + endpointData = """{"value":" lnbc1test ","min":"1","max":"10","extra":"ignored"}""", + ) + + assertEquals(MethodId.Bolt11, endpoint?.methodId) + assertEquals("lnbc1test", endpoint?.value) + assertEquals("1", endpoint?.min) + assertEquals("10", endpoint?.max) + } + + @Test + fun `parseEndpoint rejects legacy lnurl pay id`() { + val endpoint = PublicPaykitRepo.parseEndpoint( + methodId = "btc-lightning-lnurl-pay", + endpointData = """{"value":"lnurl1test"}""", + ) + + assertNull(endpoint) + } + + @Test + fun `parseEndpoint rejects raw string payloads`() { + val endpoint = PublicPaykitRepo.parseEndpoint( + methodId = "btc-bitcoin-p2wpkh", + endpointData = "bc1qexampleaddress", + ) + + assertNull(endpoint) + } + + @Test + fun `parseEndpoint rejects unsupported method ids`() { + val endpoint = PublicPaykitRepo.parseEndpoint( + methodId = "btc-lightning-bolt12", + endpointData = """{"value":"lni1test"}""", + ) + + assertNull(endpoint) + } + + @Test + fun `parseEndpoint accepts lnurl method id`() { + val endpoint = PublicPaykitRepo.parseEndpoint( + methodId = "btc-lightning-lnurl", + endpointData = """{"value":"lnurl1test"}""", + ) + + assertEquals(MethodId.Lnurl, endpoint?.methodId) + assertEquals("lnurl1test", endpoint?.value) + } + + @Test + fun `serializePayload trims and wraps value`() { + assertEquals("""{"value":"bc1ptest"}""", PublicPaykitRepo.serializePayload(" bc1ptest ")) + } + + @Test + fun `serializePayload rejects empty values`() { + assertFailsWith { + PublicPaykitRepo.serializePayload(" ") + } + } + + @Test + fun `paymentRequest prefers bip21 with bolt11 when both are payable`() { + val request = PublicPaykitRepo.paymentRequest( + listOf( + endpoint(MethodId.Bolt11, "lnbc1test"), + endpoint(MethodId.P2tr, "bc1ptest"), + ), + ) + + assertEquals("bitcoin:bc1ptest?lightning=lnbc1test", request) + } + + @Test + fun `paymentRequest prefers taproot among multiple onchain endpoints`() { + val request = PublicPaykitRepo.paymentRequest( + listOf( + endpoint(MethodId.P2wpkh, "bc1qtest"), + endpoint(MethodId.P2tr, "bc1ptest"), + ), + ) + + assertEquals("bc1ptest", request) + } + + @Test + fun `paymentRequest falls back to preferred single endpoint`() { + assertEquals( + "lnbc1test", + PublicPaykitRepo.paymentRequest(listOf(endpoint(MethodId.Bolt11, "lnbc1test"))), + ) + assertEquals( + "lnurl1test", + PublicPaykitRepo.paymentRequest(listOf(endpoint(MethodId.Lnurl, "lnurl1test"))), + ) + assertEquals( + "bc1ptest", + PublicPaykitRepo.paymentRequest(listOf(endpoint(MethodId.P2tr, "bc1ptest"))), + ) + } + + @Test + fun `paymentRequest returns empty string for empty endpoints`() { + assertEquals("", PublicPaykitRepo.paymentRequest(emptyList())) + } + + @Test + fun `method ids match Paykit grammar`() { + val pattern = Regex("^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$") + + MethodId.entries.forEach { + assertTrue(pattern.matches(it.rawValue), "Invalid method id '${it.rawValue}'") + } + } + + @Test + fun `onchainMethodId selects address method id`() { + assertEquals(MethodId.P2tr, PublicPaykitRepo.onchainMethodId("bc1ptest")) + assertEquals(MethodId.P2wpkh, PublicPaykitRepo.onchainMethodId("tb1qtest")) + assertEquals(MethodId.P2sh, PublicPaykitRepo.onchainMethodId("2test")) + assertEquals(MethodId.P2pkh, PublicPaykitRepo.onchainMethodId("1test")) + } + + private fun endpoint(methodId: MethodId, value: String) = Endpoint( + methodId = methodId, + value = value, + rawPayload = """{"value":"$value"}""", + ) +} diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt index 143d3a2174..b1f632d656 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt @@ -12,6 +12,7 @@ import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertNull @@ -20,6 +21,7 @@ import kotlin.test.assertNull class AddContactViewModelTest : BaseUnitTest() { private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() + private val publicPaykitRepo: PublicPaykitRepo = mock() @Test fun `self add failure should show dedicated error`() = test { @@ -47,10 +49,24 @@ class AddContactViewModelTest : BaseUnitTest() { assertNull(sut.uiState.value.fetchedProfile) } + @Test + fun `existing contact failure should show dedicated error`() = test { + whenever(context.getString(R.string.contacts__add_error_existing)).thenReturn("existing contact") + whenever(pubkyRepo.fetchContactProfile(any())) + .thenReturn(Result.failure(PubkyContactError.AlreadyExists)) + + val sut = createSut() + advanceUntilIdle() + + assertEquals("existing contact", sut.uiState.value.error) + assertNull(sut.uiState.value.fetchedProfile) + } + @Test fun `successful fetch should populate profile`() = test { val profile = PubkyProfile.placeholder(TEST_PUBLIC_KEY) whenever(pubkyRepo.fetchContactProfile(TEST_PUBLIC_KEY)).thenReturn(Result.success(profile)) + whenever(publicPaykitRepo.hasPayablePublicEndpoint(TEST_PUBLIC_KEY)).thenReturn(Result.success(false)) val sut = createSut() advanceUntilIdle() @@ -63,6 +79,7 @@ class AddContactViewModelTest : BaseUnitTest() { return AddContactViewModel( context = context, pubkyRepo = pubkyRepo, + publicPaykitRepo = publicPaykitRepo, savedStateHandle = SavedStateHandle(mapOf("publicKey" to publicKey)), ) } diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt index da4b5c3984..1b76dffdc1 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportFlowTest.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.screens.contacts import org.junit.Test +import to.bitkit.models.PubkyProfile import kotlin.test.assertEquals class ContactImportFlowTest { @@ -32,6 +33,18 @@ class ContactImportFlowTest { ) } + @Test + fun `resolveAddContactValidation returns existing contact for saved contact`() { + assertEquals( + AddContactValidationResult.ExistingContact, + resolveAddContactValidation( + input = VALID_PUBLIC_KEY, + ownPublicKey = null, + contacts = listOf(PubkyProfile.placeholder(VALID_PUBLIC_KEY)), + ), + ) + } + @Test fun `resolveAddContactValidation returns normalized key for valid input`() { val rawKey = VALID_PUBLIC_KEY.removePrefix("pubky") diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index df4f5568bf..cfaa481b7d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -33,6 +33,7 @@ import to.bitkit.repositories.LightningState import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState @@ -73,6 +74,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val coreService = mock() private val keychain = mock() private val pubkyRepo = mock() + private val publicPaykitRepo = mock() private val widgetsRepo = mock() private val formatMoneyValue = mock() @@ -130,6 +132,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { transferRepo = transferRepo, migrationService = migrationService, coreService = coreService, + publicPaykitRepo = publicPaykitRepo, appUpdateSheet = mock(), backupSheet = mock(), notificationsSheet = mock(), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27084a1ecf..1cacf9e64c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,8 +20,8 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.56" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc1" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.58" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc3" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From aab781c61158e7ae16d85037a5d6ce75bfbbc86f Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 30 Apr 2026 11:22:22 -0700 Subject: [PATCH 02/35] fix: allow contact replacement --- .../main/java/to/bitkit/repositories/PubkyRepo.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 6614fe0e67..e47469389e 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -634,7 +634,10 @@ class PubkyRepo @Inject constructor( val session = requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } - val prefixedKey = requireAddableContactPublicKey(publicKey) + val prefixedKey = requireAddableContactPublicKey( + publicKey = publicKey, + allowExisting = existingProfile != null, + ) val profile = existingProfile?.copy(publicKey = prefixedKey) ?: run { val ffiProfile = pubkyService.getProfile(prefixedKey) PubkyProfile.fromFfi(prefixedKey, ffiProfile) @@ -946,16 +949,16 @@ class PubkyRepo @Inject constructor( clearAuthenticatedState() } - private fun requireAddableContactPublicKey(publicKey: String): String { + private fun requireAddableContactPublicKey(publicKey: String, allowExisting: Boolean = false): String { val prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) - contactValidationError(prefixedKey)?.let { throw it } + contactValidationError(prefixedKey, allowExisting)?.let { throw it } return checkNotNull(prefixedKey) { "Normalized pubky key is required" } } - private fun contactValidationError(prefixedKey: String?): PubkyContactError? { + private fun contactValidationError(prefixedKey: String?, allowExisting: Boolean = false): PubkyContactError? { if (prefixedKey == null) return PubkyContactError.InvalidFormat if (_publicKey.value == prefixedKey) return PubkyContactError.CannotAddSelf - if (_contacts.value.any { PubkyPublicKeyFormat.matches(it.publicKey, prefixedKey) }) { + if (!allowExisting && _contacts.value.any { PubkyPublicKeyFormat.matches(it.publicKey, prefixedKey) }) { return PubkyContactError.AlreadyExists } return null From 758ea69211478ac1418f8d77c41f0fe2130980ae Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 30 Apr 2026 11:27:38 -0700 Subject: [PATCH 03/35] fix: address review comments --- CHANGELOG.md | 3 --- .../test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt | 3 ++- changelog.d/next/924.added.md | 1 + 3 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 changelog.d/next/924.added.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 864c04704f..db4df41e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added -- Support public Paykit contact payments #924 - ### Changed - Improve Pubky profile restore, contact editing, and contact routing flows #905 diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 85960fc8d8..a67fe493c9 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -1,12 +1,13 @@ package to.bitkit.repositories import org.junit.Test +import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue -class PublicPaykitRepoTest { +class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `parseEndpoint accepts Paykit JSON payloads`() { val endpoint = PublicPaykitRepo.parseEndpoint( diff --git a/changelog.d/next/924.added.md b/changelog.d/next/924.added.md new file mode 100644 index 0000000000..c8fbe3159b --- /dev/null +++ b/changelog.d/next/924.added.md @@ -0,0 +1 @@ +Support public Paykit contact payments. From 4219bae8f0a4678f46b201579c8572b41b596641 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 30 Apr 2026 11:53:33 -0700 Subject: [PATCH 04/35] fix: address claude comments --- .../bitkit/ui/screens/contacts/ContactActivityViewModel.kt | 6 ++++++ app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt index d5e002e394..3daef71e1e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt @@ -21,6 +21,7 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.PubkyRepo +import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel @@ -30,6 +31,9 @@ class ContactActivityViewModel @Inject constructor( private val pubkyRepo: PubkyRepo, savedStateHandle: SavedStateHandle, ) : ViewModel() { + companion object { + private const val TAG = "ContactActivityViewModel" + } private val publicKey: String = checkNotNull( savedStateHandle["publicKey"], @@ -59,6 +63,7 @@ class ContactActivityViewModel @Inject constructor( } } .onFailure { + Logger.warn("Failed to load contact activity for '$publicKey'", it, context = TAG) _uiState.update { it.copy( isLoading = false, @@ -78,6 +83,7 @@ class ContactActivityViewModel @Inject constructor( pubkyRepo.fetchContactProfile(publicKey) .onSuccess { profile -> _uiState.update { it.copy(profile = profile) } } .onFailure { + Logger.warn("Failed to load contact profile for '$publicKey'", it, context = TAG) _uiState.update { it.copy(profile = PubkyProfile.placeholder(publicKey)) } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 817cc8aa73..c9668b2b1c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2579,9 +2579,9 @@ class AppViewModel @Inject constructor( private fun syncContactForActivity(paymentHashOrTxId: String) { val contactContext = synchronized(contactPaymentContextLock) { - val context = pendingContactPaymentContexts.remove(paymentHashOrTxId) - ?: activeContactPaymentContext - if (context != null) { + val pendingContext = pendingContactPaymentContexts.remove(paymentHashOrTxId) + val context = pendingContext ?: activeContactPaymentContext + if (pendingContext == null && context != null) { activeContactPaymentContext = null } context From fa64cb4a2a64fa7c9392b1cf4212cfd87097da26 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 30 Apr 2026 12:04:21 -0700 Subject: [PATCH 05/35] Update ContactActivityViewModel.kt Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt index 3daef71e1e..c12f0e1be5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt @@ -63,7 +63,6 @@ class ContactActivityViewModel @Inject constructor( } } .onFailure { - Logger.warn("Failed to load contact activity for '$publicKey'", it, context = TAG) _uiState.update { it.copy( isLoading = false, From 266f923542072219c23040a8f54c430e2517a280 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 09:36:53 -0500 Subject: [PATCH 06/35] fix: align add contact send --- .../ui/screens/contacts/AddContactScreen.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt index c420a1f1ba..2059649a5e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt @@ -457,15 +457,6 @@ private fun LoadedContent( imageUrl = profile.imageUrl, ) - if (hasPublicPaymentEndpoint) { - VerticalSpacer(24.dp) - SecondaryButton( - text = stringResource(R.string.wallet__send), - onClick = onPay, - modifier = Modifier.testTag("AddContactPay") - ) - } - FillHeight() BodyS( @@ -474,6 +465,15 @@ private fun LoadedContent( ) VerticalSpacer(16.dp) + if (hasPublicPaymentEndpoint) { + SecondaryButton( + text = stringResource(R.string.wallet__send), + onClick = onPay, + modifier = Modifier.testTag("AddContactPay") + ) + VerticalSpacer(16.dp) + } + Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() From 2d7d9ef148584321df5c9eec2ce690f7f0f103d9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 09:47:20 -0500 Subject: [PATCH 07/35] fix: preload contact payments --- .../screens/contacts/AddContactViewModel.kt | 26 ++++++++++--------- .../contacts/ContactDetailViewModel.kt | 23 ++++++++-------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index 14232a0e01..b6b85ad001 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -67,8 +67,14 @@ class AddContactViewModel @Inject constructor( } pubkyRepo.fetchContactProfile(publicKey) .onSuccess { profile -> - _uiState.update { it.copy(fetchedProfile = profile, isLoading = false) } - loadPaymentEndpoint(profile.publicKey) + val hasEndpoint = loadPaymentEndpoint(profile.publicKey) + _uiState.update { + it.copy( + fetchedProfile = profile, + hasPublicPaymentEndpoint = hasEndpoint, + isLoading = false, + ) + } } .onFailure { error -> _uiState.update { state -> @@ -90,16 +96,12 @@ class AddContactViewModel @Inject constructor( } } - private fun loadPaymentEndpoint(publicKey: String) { - viewModelScope.launch { - publicPaykitRepo.hasPayablePublicEndpoint(publicKey) - .onSuccess { hasEndpoint -> - _uiState.update { it.copy(hasPublicPaymentEndpoint = hasEndpoint) } - } - .onFailure { - Logger.warn("Failed to load public Paykit endpoint for '$publicKey'", it, context = TAG) - } - } + private suspend fun loadPaymentEndpoint(publicKey: String): Boolean { + return publicPaykitRepo.hasPayablePublicEndpoint(publicKey) + .onFailure { + Logger.warn("Failed to load public Paykit endpoint for '$publicKey'", it, context = TAG) + } + .getOrDefault(false) } fun payContact() { diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index a03d7a1b36..e1d77b78a7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -53,30 +53,33 @@ class ContactDetailViewModel @Inject constructor( init { loadContact() - loadPaymentEndpoint() observeContactUpdates() } fun loadContact() { viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } val cached = pubkyRepo.contacts.value.find { it.publicKey == publicKey } if (cached != null) { + val hasEndpoint = loadPaymentEndpoint() _uiState.update { it.copy( profile = cached, tags = cached.tags.toImmutableList(), + hasPublicPaymentEndpoint = hasEndpoint, isLoading = false, ) } return@launch } - _uiState.update { it.copy(isLoading = true) } pubkyRepo.fetchContactProfile(publicKey) .onSuccess { profile -> + val hasEndpoint = loadPaymentEndpoint() _uiState.update { it.copy( profile = profile, tags = profile.tags.toImmutableList(), + hasPublicPaymentEndpoint = hasEndpoint, isLoading = false, ) } @@ -92,16 +95,12 @@ class ContactDetailViewModel @Inject constructor( } } - private fun loadPaymentEndpoint() { - viewModelScope.launch { - publicPaykitRepo.hasPayablePublicEndpoint(publicKey) - .onSuccess { hasEndpoint -> - _uiState.update { it.copy(hasPublicPaymentEndpoint = hasEndpoint) } - } - .onFailure { - Logger.warn("Failed to load public Paykit endpoint for '$publicKey'", it, context = TAG) - } - } + private suspend fun loadPaymentEndpoint(): Boolean { + return publicPaykitRepo.hasPayablePublicEndpoint(publicKey) + .onFailure { + Logger.warn("Failed to load public Paykit endpoint for '$publicKey'", it, context = TAG) + } + .getOrDefault(false) } fun payContact() { From 6f9aa726ce78d8acbe5a15b4b7a9c31724aeee61 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 09:52:26 -0500 Subject: [PATCH 08/35] fix: align onchain paykit --- app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index d99858d654..87715030e4 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -243,8 +243,7 @@ class PublicPaykitRepo @Inject constructor( -> { val scan = coreService.decode(endpoint.paymentRequest) as? Scanner.OnChain ?: return@runCatching false val address = validateBitcoinAddress(scan.invoice.address) - onchainMethodId(scan.invoice.address) == endpoint.methodId && - !NetworkValidationHelper.isNetworkMismatch(address.network.toLdkNetwork(), Env.network) + !NetworkValidationHelper.isNetworkMismatch(address.network.toLdkNetwork(), Env.network) } } }.getOrDefault(false) From 263a29731d3b2d9e1ea08f29ad8ae315a8a90d01 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 10:01:12 -0500 Subject: [PATCH 09/35] fix: gate contact details loading --- .../java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index 437be42baf..c2e3538bce 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -114,7 +114,7 @@ private fun Content( ) when { - uiState.isLoading && currentProfile == null -> LoadingState() + uiState.isLoading -> LoadingState() currentProfile != null -> ContactBody( profile = currentProfile, tags = uiState.tags, From 2da3269d6e1dd9f6369303dfee233af58d8aaaca Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 10:11:10 -0500 Subject: [PATCH 10/35] fix: preserve pubky session --- app/src/main/java/to/bitkit/repositories/PubkyRepo.kt | 6 +----- app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index e47469389e..9b1a1a0ddf 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -206,9 +206,7 @@ class PubkyRepo @Inject constructor( ): InitResult = withContext(ioDispatcher) { if (storedSecretKeyHex.isNullOrEmpty()) { if (!savedSessionSecret.isNullOrEmpty()) { - Logger.warn("Skipped re-sign-in recovery, no secret key available", context = TAG) - runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } - notifyBackupStateChanged() + Logger.warn("Skipped re-sign-in recovery, keeping saved session", context = TAG) InitResult.RestorationFailed } else { InitResult.NoSession @@ -223,8 +221,6 @@ class PubkyRepo @Inject constructor( InitResult.Restored(publicKey) }.getOrElse { Logger.error("Failed re-sign-in recovery", it, context = TAG) - runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } - notifyBackupStateChanged() InitResult.RestorationFailed } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 67acbd04f2..aa77940262 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -465,7 +465,7 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `initialize should delete stale saved session when re-sign-in is unavailable`() = test { + fun `initialize should keep saved session when re-sign-in is unavailable`() = test { val session = "stale_session" whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) @@ -475,7 +475,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(sut.sessionRestorationFailed.value) assertFalse(sut.isAuthenticated.value) - verifyBlocking(keychain) { delete(Keychain.Key.PAYKIT_SESSION.name) } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } } @Test From c1b8120dd8e96ca6626a19275b989f47cfbc314a Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 10:26:25 -0500 Subject: [PATCH 11/35] fix: load contacts after auth --- app/src/main/java/to/bitkit/repositories/PubkyRepo.kt | 1 + .../test/java/to/bitkit/repositories/PubkyRepoTest.kt | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 9b1a1a0ddf..147eb33b7c 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -262,6 +262,7 @@ class PubkyRepo @Inject constructor( _authState.update { PubkyAuthState.Authenticated } Logger.info("Completed pubky auth for '$pk'", context = TAG) loadProfile() + loadContacts() }.map { } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index aa77940262..6a30ade135 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -105,6 +105,8 @@ class PubkyRepoTest : BaseUnitTest() { val ffiProfile = mock() whenever(ffiProfile.name).thenReturn("User") whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) + whenever(pubkyService.sessionList(testSecret, Env.contactsBasePath)).thenReturn(emptyList()) val result = sut.completeAuthentication() @@ -122,6 +124,8 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) val ffiProfile = createFfiProfile(name = "User") whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) + whenever(pubkyService.sessionList(testSecret, Env.contactsBasePath)).thenReturn(emptyList()) val result = sut.completeAuthentication() @@ -130,18 +134,20 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `completeAuthentication should not load contacts automatically`() = test { + fun `completeAuthentication should load contacts automatically`() = test { val testSecret = "session_secret" val testPk = VALID_SELF_KEY.removePrefix("pubky") whenever(pubkyService.completeAuth()).thenReturn(testSecret) whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) val ffiProfile = createFfiProfile(name = "User") whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(ffiProfile) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) + whenever(pubkyService.sessionList(testSecret, Env.contactsBasePath)).thenReturn(emptyList()) val result = sut.completeAuthentication() assertTrue(result.isSuccess) - verify(pubkyService, never()).sessionList(any(), any()) + verify(pubkyService).sessionList(testSecret, Env.contactsBasePath) } @Test From 37eb079c69a800f5e5559eeb7002cea61600c4ff Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 10:32:43 -0500 Subject: [PATCH 12/35] fix: show contact activity titles --- app/src/main/java/to/bitkit/ext/Activities.kt | 5 ++++ .../to/bitkit/repositories/ActivityRepo.kt | 6 +---- .../activity/components/ActivityRow.kt | 23 ++++++++++++++++++- .../viewmodels/ActivityListViewModel.kt | 4 ++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 39f50f23b7..89708073bb 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -52,6 +52,11 @@ fun Activity.isSent() = when (this) { is Activity.Onchain -> v1.txType == PaymentType.SENT } +fun Activity.contact(): String? = when (this) { + is Activity.Lightning -> v1.contact + is Activity.Onchain -> v1.contact +} + fun Activity.matchesPaymentId(paymentHashOrTxId: String): Boolean = when (this) { is Activity.Lightning -> paymentHashOrTxId == v1.id is Activity.Onchain -> paymentHashOrTxId == v1.txId diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index d21fe879f2..59a769335f 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -35,6 +35,7 @@ import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.di.BgDispatcher import to.bitkit.di.IoDispatcher import to.bitkit.ext.amountOnClose +import to.bitkit.ext.contact import to.bitkit.ext.matchesPaymentId import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp @@ -404,11 +405,6 @@ class ActivityRepo @Inject constructor( coreService.activity.getActivity(forPaymentId) ?: getOnchainActivityByTxId(forPaymentId)?.let { Activity.Onchain(it) } - private fun Activity.contact(): String? = when (this) { - is Activity.Lightning -> v1.contact - is Activity.Onchain -> v1.contact - } - private fun Activity.withContact(normalizedKey: String, updatedAt: ULong): Activity = when (this) { is Activity.Lightning -> Activity.Lightning(v1.copy(contact = normalizedKey, updatedAt = updatedAt)) is Activity.Onchain -> Activity.Onchain(v1.copy(contact = normalizedKey, updatedAt = updatedAt)) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 527f7ab455..8700e633f3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -31,6 +31,7 @@ import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R import to.bitkit.ext.DatePattern +import to.bitkit.ext.contact import to.bitkit.ext.formatted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer @@ -40,6 +41,8 @@ import to.bitkit.ext.totalValue import to.bitkit.ext.txType import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies @@ -90,6 +93,19 @@ fun ActivityRow( val isTransfer = item.isTransfer() val activityListViewModel = activityListViewModel + val contacts by activityListViewModel?.contacts?.collectAsStateWithLifecycle() ?: remember { + mutableStateOf(emptyList()) + } + val contactName = remember(item, contacts) { contactName(item, contacts) } + val contactTitle = contactName?.let { + val titleRes = if (item.isSent()) { + R.string.contacts__activity_sent_to + } else { + R.string.contacts__activity_received_from + } + stringResource(titleRes, it) + } + val resolvedTitle = title ?: contactTitle var isCpfpChild by remember { mutableStateOf(false) } LaunchedEffect(item) { @@ -121,7 +137,7 @@ fun ActivityRow( status = status, isTransfer = isTransfer, isCpfpChild = isCpfpChild, - title = title, + title = resolvedTitle, ) val context = LocalContext.current val subtitleText = when (item) { @@ -172,6 +188,11 @@ fun ActivityRow( } } +private fun contactName(activity: Activity, contacts: List): String? { + val contact = activity.contact() ?: return null + return contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, contact) }?.name +} + @Suppress("CyclomaticComplexMethod") @Composable private fun TransactionStatusText( diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index d474eae277..82208f1b6e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.ext.isTransfer import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.utils.Logger import javax.inject.Inject @@ -37,6 +38,7 @@ import javax.inject.Inject class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val activityRepo: ActivityRepo, + pubkyRepo: PubkyRepo, ) : ViewModel() { private val _filteredActivities = MutableStateFlow?>(null) val filteredActivities = _filteredActivities.asStateFlow() @@ -50,6 +52,8 @@ class ActivityListViewModel @Inject constructor( private val _latestActivities = MutableStateFlow?>(null) val latestActivities = _latestActivities.asStateFlow() + val contacts = pubkyRepo.contacts + val availableTags: StateFlow> = activityRepo.state.map { it.tags }.stateInScope(persistentListOf()) From 98b4e0cc9d276d6515b3734b71103747227a43e0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 10:39:01 -0500 Subject: [PATCH 13/35] fix: expose immutable contacts --- .../ui/screens/wallets/activity/components/ActivityRow.kt | 3 ++- .../main/java/to/bitkit/viewmodels/ActivityListViewModel.kt | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 8700e633f3..e5cca3458a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -29,6 +29,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.ext.DatePattern import to.bitkit.ext.contact @@ -94,7 +95,7 @@ fun ActivityRow( val activityListViewModel = activityListViewModel val contacts by activityListViewModel?.contacts?.collectAsStateWithLifecycle() ?: remember { - mutableStateOf(emptyList()) + mutableStateOf(persistentListOf()) } val contactName = remember(item, contacts) { contactName(item, contacts) } val contactTitle = contactName?.let { diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 82208f1b6e..b11a0d6c38 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.ext.isTransfer +import to.bitkit.models.PubkyProfile import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.screens.wallets.activity.components.ActivityTab @@ -52,7 +53,8 @@ class ActivityListViewModel @Inject constructor( private val _latestActivities = MutableStateFlow?>(null) val latestActivities = _latestActivities.asStateFlow() - val contacts = pubkyRepo.contacts + val contacts: StateFlow> = + pubkyRepo.contacts.map { it.toImmutableList() }.stateInScope(persistentListOf()) val availableTags: StateFlow> = activityRepo.state.map { it.tags }.stateInScope(persistentListOf()) From 0ad5fd322b9557501085b6728d8c8b1b940cd24f Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 11:13:05 -0500 Subject: [PATCH 14/35] fix: polish paykit contacts --- .../screens/profile/PayContactsViewModel.kt | 11 +++- .../java/to/bitkit/ui/sheets/SendSheet.kt | 1 - app/src/main/res/values/strings.xml | 4 ++ .../repositories/PublicPaykitRepoTest.kt | 61 +++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt index 36e5237579..a2b44bfafe 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.models.Toast +import to.bitkit.repositories.PublicPaykitError import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger @@ -78,7 +79,7 @@ class PayContactsViewModel @Inject constructor( ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error), - description = it.message ?: context.getString(R.string.common__error_body), + description = syncErrorMessage(it), ) _uiState.update { it.copy( @@ -89,6 +90,14 @@ class PayContactsViewModel @Inject constructor( } } } + + private fun syncErrorMessage(error: Throwable): String = when (error) { + PublicPaykitError.InvalidPayload -> context.getString(R.string.profile__pay_contacts_error_invalid_payload) + PublicPaykitError.NoSupportedEndpoint -> context.getString(R.string.profile__pay_contacts_error_no_endpoint) + PublicPaykitError.SessionNotActive -> context.getString(R.string.profile__pay_contacts_error_session) + PublicPaykitError.WalletNotReady -> context.getString(R.string.profile__pay_contacts_error_wallet) + else -> context.getString(R.string.common__error_body) + } } @Immutable diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 056be74adf..9b9431d65f 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -276,7 +276,6 @@ fun SendSheet( ) }, onPaymentPending = { paymentHash, amount -> - appViewModel.preserveContactPaymentContext(paymentHash) navController.navigateTo(SendRoute.Pending(paymentHash, amount)) { popUpTo(startDestination) { inclusive = true } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 65b5073814..f511fe4694 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -536,6 +536,10 @@ Portable\n<accent>pubky\nprofile</accent> Profile Use Bitkit with your contacts to send payments directly, anytime, anywhere. + Payment endpoint data could not be prepared. + No supported payment endpoint is available. + Reconnect your Pubky profile to share payment data. + Wallet is still starting. Try again in a moment. Let your\ncontacts\n<accent>pay you</accent> Pay Contacts Share payment data and enable payments with contacts diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index a67fe493c9..80f8f504d1 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -1,6 +1,15 @@ package to.bitkit.repositories +import com.synonym.paykit.FfiPaymentEntry +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -8,6 +17,53 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class PublicPaykitRepoTest : BaseUnitTest() { + @Test + fun `syncCurrentPublishedEndpoints sets desired endpoints and removes obsolete endpoints`() = test { + val pubkyRepo = mock() + val walletRepo = mock() + val lightningRepo = mock() + val coreService = mock() + val sut = PublicPaykitRepo( + ioDispatcher = testDispatcher, + pubkyRepo = pubkyRepo, + walletRepo = walletRepo, + lightningRepo = lightningRepo, + coreService = coreService, + ) + + whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) + whenever(walletRepo.walletState).thenReturn( + MutableStateFlow( + WalletState( + onchainAddress = "bc1ptest", + bolt11 = "lnbc1test", + ), + ), + ) + whenever(pubkyRepo.setPaymentEndpoint(any(), any())).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.removePaymentEndpoint(any())).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( + Result.success( + listOf( + paymentEntry(MethodId.Bolt11, "lnbc1old"), + paymentEntry(MethodId.P2pkh, "1obsolete"), + ), + ), + ) + + val result = sut.syncCurrentPublishedEndpoints() + + assertTrue(result.isSuccess) + inOrder(pubkyRepo) { + verify(pubkyRepo).setPaymentEndpoint(MethodId.Bolt11.rawValue, """{"value":"lnbc1test"}""") + verify(pubkyRepo).setPaymentEndpoint(MethodId.P2tr.rawValue, """{"value":"bc1ptest"}""") + verify(pubkyRepo).getPaymentList("pubkyself") + verify(pubkyRepo).removePaymentEndpoint(MethodId.P2pkh.rawValue) + } + verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.Bolt11.rawValue) + verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.P2tr.rawValue) + } + @Test fun `parseEndpoint accepts Paykit JSON payloads`() { val endpoint = PublicPaykitRepo.parseEndpoint( @@ -141,4 +197,9 @@ class PublicPaykitRepoTest : BaseUnitTest() { value = value, rawPayload = """{"value":"$value"}""", ) + + private fun paymentEntry(methodId: MethodId, value: String) = FfiPaymentEntry( + methodId = methodId.rawValue, + endpointData = """{"value":"$value"}""", + ) } From c401c8481a0ea3722aaf7647e1d53583efbd9f3e Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 11:26:22 -0500 Subject: [PATCH 15/35] fix: serialize paykit publishing --- app/src/main/java/to/bitkit/models/MSat.kt | 4 ++ .../to/bitkit/repositories/LightningRepo.kt | 6 +-- .../bitkit/repositories/PublicPaykitRepo.kt | 43 ++++++++++------ .../screens/profile/PayContactsViewModel.kt | 10 ++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +++ .../test/java/to/bitkit/models/MSatTest.kt | 10 ++++ .../repositories/PublicPaykitRepoTest.kt | 51 +++++++++++++++---- 7 files changed, 99 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/MSat.kt b/app/src/main/java/to/bitkit/models/MSat.kt index 20b0936e92..ea91b96c94 100644 --- a/app/src/main/java/to/bitkit/models/MSat.kt +++ b/app/src/main/java/to/bitkit/models/MSat.kt @@ -24,3 +24,7 @@ fun msatCeilOf(msat: ULong): ULong = MSat(msat).ceil() /** Truncate sub-sat remainder from [msat]. Use for fees and upper bounds. */ fun msatFloorOf(msat: ULong): ULong = MSat(msat).floor() + +/** Convert [sats] to millisats with saturating overflow protection. */ +fun satsToMsat(sats: ULong): ULong = + if (sats <= ULong.MAX_VALUE / MSat.PER_SAT) sats * MSat.PER_SAT else ULong.MAX_VALUE diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 9402c01d94..76bc4f89cd 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -64,12 +64,12 @@ import to.bitkit.ext.nowTimestamp import to.bitkit.ext.toPeerDetailsList import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.CoinSelectionPreference -import to.bitkit.models.MSat import to.bitkit.models.NATIVE_WITNESS_TYPES import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult import to.bitkit.models.TransactionSpeed import to.bitkit.models.safe +import to.bitkit.models.satsToMsat import to.bitkit.models.toAddressType import to.bitkit.models.toCoinSelectAlgorithm import to.bitkit.models.toCoreNetwork @@ -1396,7 +1396,7 @@ class LightningRepo @Inject constructor( context = TAG, ) val result = if (amountSats != null) { - val amountMsat = amountSats.safe() * MSat.PER_SAT.safe() + val amountMsat = satsToMsat(amountSats) lightningService.sendProbesUsingAmount(bolt11, amountMsat) } else { lightningService.sendProbes(bolt11) @@ -1411,7 +1411,7 @@ class LightningRepo @Inject constructor( "Sending keysend probe to nodeId='$nodeId' amountSats='$amountSats'", context = TAG, ) - val amountMsat = amountSats.safe() * MSat.PER_SAT.safe() + val amountMsat = satsToMsat(amountSats) lightningService.sendKeysendProbe(nodeId, amountMsat).map { ProbeDispatch(paymentIds = it) } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 87715030e4..f07ee4fa33 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -3,6 +3,8 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.validateBitcoinAddress import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -115,6 +117,8 @@ class PublicPaykitRepo @Inject constructor( } } + private val publishMutex = Mutex() + suspend fun beginPayment(publicKey: String): Result = withContext(ioDispatcher) { runCatching { val endpoints = fetchPublicEndpoints(publicKey).getOrThrow() @@ -164,33 +168,42 @@ class PublicPaykitRepo @Inject constructor( } private suspend fun removePublishedEndpoints() { - val currentMethodIds = currentPublishedMethodIds() - publishableMethodIds - .filter { it.rawValue in currentMethodIds } - .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } + publishMutex.withLock { + val currentMethodIds = currentPublishedMethodIds() + publishableMethodIds + .filter { it.rawValue in currentMethodIds } + .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } + } } private suspend fun applyPublishedEndpoints(desiredEndpoints: List) { - val desiredMethodIds = desiredEndpoints.map { it.methodId.rawValue }.toSet() + publishMutex.withLock { + requireCurrentPublicKey() + val desiredMethodIds = desiredEndpoints.map { it.methodId.rawValue }.toSet() - desiredEndpoints.forEach { - pubkyRepo.setPaymentEndpoint(it.methodId.rawValue, it.rawPayload).getOrThrow() - } + desiredEndpoints.forEach { + pubkyRepo.setPaymentEndpoint(it.methodId.rawValue, it.rawPayload).getOrThrow() + } - val publishedMethodIds = currentPublishedMethodIds() - publishableMethodIds - .filter { it.rawValue in publishedMethodIds && it.rawValue !in desiredMethodIds } - .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } + val publishedMethodIds = currentPublishedMethodIds() + publishableMethodIds + .filter { it.rawValue in publishedMethodIds && it.rawValue !in desiredMethodIds } + .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } + } } private suspend fun currentPublishedMethodIds(): Set { + return pubkyRepo.getPaymentList(requireCurrentPublicKey()).getOrThrow() + .map { it.methodId } + .toSet() + } + + private suspend fun requireCurrentPublicKey(): String { val currentPublicKey = pubkyRepo.publicKey.value ?: pubkyRepo.currentPublicKey().getOrThrow() if (currentPublicKey == null) throw PublicPaykitError.SessionNotActive - return pubkyRepo.getPaymentList(currentPublicKey).getOrThrow() - .map { it.methodId } - .toSet() + return currentPublicKey } private suspend fun buildWalletEndpoints(refresh: Boolean): List { diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt index a2b44bfafe..41cc7fbb82 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.Toast import to.bitkit.repositories.PublicPaykitError @@ -44,8 +45,7 @@ class PayContactsViewModel @Inject constructor( val settings = settingsStore.data.first() _uiState.update { it.copy( - isPaymentSharingEnabled = settings.sharesPublicPaykitEndpoints || - !settings.hasConfirmedPublicPaykitEndpoints, + isPaymentSharingEnabled = resolvedSharingDefault(settings), ) } } @@ -73,8 +73,7 @@ class PayContactsViewModel @Inject constructor( } .onFailure { val settings = settingsStore.data.first() - val persistedValue = settings.sharesPublicPaykitEndpoints || - !settings.hasConfirmedPublicPaykitEndpoints + val persistedValue = resolvedSharingDefault(settings) Logger.error("Failed to sync public Paykit endpoints", it, context = TAG) ToastEventBus.send( type = Toast.ToastType.ERROR, @@ -98,6 +97,9 @@ class PayContactsViewModel @Inject constructor( PublicPaykitError.WalletNotReady -> context.getString(R.string.profile__pay_contacts_error_wallet) else -> context.getString(R.string.common__error_body) } + + private fun resolvedSharingDefault(settings: SettingsData): Boolean = + settings.sharesPublicPaykitEndpoints || !settings.hasConfirmedPublicPaykitEndpoints } @Immutable diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c9668b2b1c..57983be169 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -33,6 +33,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async @@ -45,6 +46,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -151,6 +153,7 @@ import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) @@ -368,11 +371,13 @@ class AppViewModel @Inject constructor( } } + @OptIn(FlowPreview::class) private fun observePublicPaykitEndpoints() { viewModelScope.launch { walletRepo.walletState .map { it.bolt11 to it.onchainAddress } .distinctUntilChanged() + .debounce(PUBLIC_PAYKIT_SYNC_DEBOUNCE) .collect { (bolt11, onchainAddress) -> val shouldPublish = settingsStore.data.first().sharesPublicPaykitEndpoints if (!shouldPublish) return@collect @@ -2701,6 +2706,7 @@ class AppViewModel @Inject constructor( private const val AUTH_CHECK_INITIAL_DELAY_MS = 1000L private const val AUTH_CHECK_SPLASH_DELAY_MS = 500L private const val ADDRESS_VALIDATION_DEBOUNCE_MS = 1000L + private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private const val PUBKYAUTH_SCHEME = "pubkyauth" } } diff --git a/app/src/test/java/to/bitkit/models/MSatTest.kt b/app/src/test/java/to/bitkit/models/MSatTest.kt index fc17e8ebce..e5aa981c72 100644 --- a/app/src/test/java/to/bitkit/models/MSatTest.kt +++ b/app/src/test/java/to/bitkit/models/MSatTest.kt @@ -74,5 +74,15 @@ class MSatTest { fun `msatFloorOf matches MSat floor`() { assertEquals(MSat(1500uL).floor(), msatFloorOf(1500uL)) } + + @Test + fun `satsToMsat converts sats to msats`() { + assertEquals(42_000uL, satsToMsat(42uL)) + } + + @Test + fun `satsToMsat saturates when conversion would overflow`() { + assertEquals(ULong.MAX_VALUE, satsToMsat(ULong.MAX_VALUE)) + } // endregion } diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 80f8f504d1..cfd24e29b2 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -21,15 +21,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { fun `syncCurrentPublishedEndpoints sets desired endpoints and removes obsolete endpoints`() = test { val pubkyRepo = mock() val walletRepo = mock() - val lightningRepo = mock() - val coreService = mock() - val sut = PublicPaykitRepo( - ioDispatcher = testDispatcher, - pubkyRepo = pubkyRepo, - walletRepo = walletRepo, - lightningRepo = lightningRepo, - coreService = coreService, - ) + val sut = createRepo(pubkyRepo = pubkyRepo, walletRepo = walletRepo) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) whenever(walletRepo.walletState).thenReturn( @@ -64,6 +56,22 @@ class PublicPaykitRepoTest : BaseUnitTest() { verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.P2tr.rawValue) } + @Test + fun `syncCurrentPublishedEndpoints returns SessionNotActive when no pubky session exists`() = test { + val pubkyRepo = mock() + val walletRepo = mock() + val sut = createRepo(pubkyRepo = pubkyRepo, walletRepo = walletRepo) + + whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null)) + whenever(pubkyRepo.currentPublicKey()).thenReturn(Result.success(null)) + whenever(walletRepo.walletState).thenReturn(MutableStateFlow(WalletState(bolt11 = "lnbc1test"))) + + val error = sut.syncCurrentPublishedEndpoints().exceptionOrNull() + + assertEquals(PublicPaykitError.SessionNotActive, error) + verify(pubkyRepo, never()).setPaymentEndpoint(any(), any()) + } + @Test fun `parseEndpoint accepts Paykit JSON payloads`() { val endpoint = PublicPaykitRepo.parseEndpoint( @@ -170,6 +178,18 @@ class PublicPaykitRepoTest : BaseUnitTest() { ) } + @Test + fun `paymentRequest prefers lnurl over onchain when bolt11 is unavailable`() { + val request = PublicPaykitRepo.paymentRequest( + listOf( + endpoint(MethodId.P2tr, "bc1ptest"), + endpoint(MethodId.Lnurl, "lnurl1test"), + ), + ) + + assertEquals("lnurl1test", request) + } + @Test fun `paymentRequest returns empty string for empty endpoints`() { assertEquals("", PublicPaykitRepo.paymentRequest(emptyList())) @@ -198,6 +218,19 @@ class PublicPaykitRepoTest : BaseUnitTest() { rawPayload = """{"value":"$value"}""", ) + private fun createRepo( + pubkyRepo: PubkyRepo = mock(), + walletRepo: WalletRepo = mock(), + lightningRepo: LightningRepo = mock(), + coreService: CoreService = mock(), + ) = PublicPaykitRepo( + ioDispatcher = testDispatcher, + pubkyRepo = pubkyRepo, + walletRepo = walletRepo, + lightningRepo = lightningRepo, + coreService = coreService, + ) + private fun paymentEntry(methodId: MethodId, value: String) = FfiPaymentEntry( methodId = methodId.rawValue, endpointData = """{"value":"$value"}""", From b7e779184bf42d663e0e272d926fb87a9f115602 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 13:24:38 -0500 Subject: [PATCH 16/35] fix: isolate paykit invoice --- CHANGELOG.md | 2 -- .../to/bitkit/repositories/PublicPaykitRepo.kt | 15 +++++++++++---- .../to/bitkit/viewmodels/ProbingToolViewModel.kt | 3 ++- .../bitkit/repositories/PublicPaykitRepoTest.kt | 15 +++++++++++---- changelog.d/next/906.fixed.md | 1 + changelog.d/next/920.fixed.md | 1 + 6 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 changelog.d/next/906.fixed.md create mode 100644 changelog.d/next/920.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index db4df41e2b..43fec9fef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve Pubky profile restore, contact editing, and contact routing flows #905 ### Fixed -- Fix probe results and add keysend probes #920 -- Align top bar back arrow and passphrase input cursor/placeholder with iOS #906 - Polish Terms of Use screen padding to match iOS #903 ## [2.2.0] - 2026-04-07 diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index f07ee4fa33..20d4558337 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -211,18 +211,19 @@ class PublicPaykitRepo @Inject constructor( lightningRepo.executeWhenNodeRunning( operationName = "sync public Paykit endpoints", ) { - walletRepo.refreshBip21() + Result.success(Unit) }.getOrThrow() } val state = walletRepo.walletState.value val endpoints = mutableListOf() + val publicBolt11 = buildPublicBolt11() - if (state.bolt11.isNotBlank()) { + if (publicBolt11.isNotBlank()) { endpoints += Endpoint( methodId = MethodId.Bolt11, - value = state.bolt11, - rawPayload = serializePayload(state.bolt11), + value = publicBolt11, + rawPayload = serializePayload(publicBolt11), ) } @@ -241,6 +242,12 @@ class PublicPaykitRepo @Inject constructor( return endpoints } + private suspend fun buildPublicBolt11(): String { + if (!lightningRepo.canReceive()) return "" + + return lightningRepo.createInvoice(amountSats = null, description = "").getOrThrow() + } + private suspend fun isPayable(endpoint: Endpoint): Boolean = runCatching { when (endpoint.methodId) { MethodId.Bolt11 -> { diff --git a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt index 3f55336591..372269bc8d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -19,6 +19,7 @@ import to.bitkit.ext.minSendableSat import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.Toast +import to.bitkit.models.satsToMsat import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.ProbeError import to.bitkit.repositories.ProbeOutcome @@ -230,7 +231,7 @@ class ProbingToolViewModel @Inject constructor( is Scanner.LnurlPay -> { val amount = amountSats ?: return@runCatching null - lightningRepo.fetchLnurlInvoice(decoded.data.callback, amount * 1000u).getOrThrow().bolt11 + lightningRepo.fetchLnurlInvoice(decoded.data.callback, satsToMsat(amount)).getOrThrow().bolt11 } else -> null diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index cfd24e29b2..17bc032fbb 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -21,17 +21,24 @@ class PublicPaykitRepoTest : BaseUnitTest() { fun `syncCurrentPublishedEndpoints sets desired endpoints and removes obsolete endpoints`() = test { val pubkyRepo = mock() val walletRepo = mock() - val sut = createRepo(pubkyRepo = pubkyRepo, walletRepo = walletRepo) + val lightningRepo = mock() + val sut = createRepo( + pubkyRepo = pubkyRepo, + walletRepo = walletRepo, + lightningRepo = lightningRepo, + ) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) whenever(walletRepo.walletState).thenReturn( MutableStateFlow( WalletState( onchainAddress = "bc1ptest", - bolt11 = "lnbc1test", + bolt11 = "lnbc1user", ), ), ) + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.createInvoice(null, "")).thenReturn(Result.success("lnbc1public")) whenever(pubkyRepo.setPaymentEndpoint(any(), any())).thenReturn(Result.success(Unit)) whenever(pubkyRepo.removePaymentEndpoint(any())).thenReturn(Result.success(Unit)) whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( @@ -47,7 +54,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) inOrder(pubkyRepo) { - verify(pubkyRepo).setPaymentEndpoint(MethodId.Bolt11.rawValue, """{"value":"lnbc1test"}""") + verify(pubkyRepo).setPaymentEndpoint(MethodId.Bolt11.rawValue, """{"value":"lnbc1public"}""") verify(pubkyRepo).setPaymentEndpoint(MethodId.P2tr.rawValue, """{"value":"bc1ptest"}""") verify(pubkyRepo).getPaymentList("pubkyself") verify(pubkyRepo).removePaymentEndpoint(MethodId.P2pkh.rawValue) @@ -64,7 +71,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null)) whenever(pubkyRepo.currentPublicKey()).thenReturn(Result.success(null)) - whenever(walletRepo.walletState).thenReturn(MutableStateFlow(WalletState(bolt11 = "lnbc1test"))) + whenever(walletRepo.walletState).thenReturn(MutableStateFlow(WalletState(onchainAddress = "bc1ptest"))) val error = sut.syncCurrentPublishedEndpoints().exceptionOrNull() diff --git a/changelog.d/next/906.fixed.md b/changelog.d/next/906.fixed.md new file mode 100644 index 0000000000..4560600b96 --- /dev/null +++ b/changelog.d/next/906.fixed.md @@ -0,0 +1 @@ +Align top bar back arrow and passphrase input cursor and placeholder styling with iOS. diff --git a/changelog.d/next/920.fixed.md b/changelog.d/next/920.fixed.md new file mode 100644 index 0000000000..f4513dca67 --- /dev/null +++ b/changelog.d/next/920.fixed.md @@ -0,0 +1 @@ +Fix probe results and add keysend probes. From 185de8c50bc873666ee2ac4cd744dd5cf7bfe444 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 13:36:55 -0500 Subject: [PATCH 17/35] fix: harden contact paykit --- .../to/bitkit/models/PubkyPublicKeyFormat.kt | 7 +++++ .../bitkit/repositories/PublicPaykitRepo.kt | 12 +++------ .../screens/contacts/AddContactViewModel.kt | 14 ++++++++-- .../contacts/ContactActivityViewModel.kt | 6 ++++- .../contacts/ContactDetailViewModel.kt | 11 +++++--- .../ui/screens/profile/PayContactsScreen.kt | 2 +- .../activity/components/ActivityRow.kt | 18 ++++++++++++- .../bitkit/models/PubkyPublicKeyFormatTest.kt | 7 +++++ .../repositories/PublicPaykitRepoTest.kt | 27 +++++++++++++++++++ 9 files changed, 86 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt b/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt index acadf9eb30..357809a533 100644 --- a/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt +++ b/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt @@ -1,10 +1,12 @@ package to.bitkit.models +import to.bitkit.ext.ellipsisMiddle import java.util.Locale object PubkyPublicKeyFormat { private const val pubkyPrefix = "pubky" private const val rawKeyLength = 52 + private const val redactedLength = 16 const val maximumInputLength = 57 private val zBase32Regex = Regex("^[ybndrfg8ejkmcpqxot1uwisza345h769]+$") @@ -32,4 +34,9 @@ object PubkyPublicKeyFormat { val normalizedRhs = rhs?.let(::normalized) ?: return false return normalizedLhs == normalizedRhs } + + fun redacted(input: String): String { + val normalizedInput = normalized(input) ?: input.trim() + return normalizedInput.ellipsisMiddle(redactedLength) + } } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 20d4558337..c8c7c54305 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -55,13 +55,7 @@ class PublicPaykitRepo @Inject constructor( MethodId.P2pkh, ) - private val publishableMethodIds = listOf( - MethodId.Bolt11, - MethodId.P2tr, - MethodId.P2wpkh, - MethodId.P2sh, - MethodId.P2pkh, - ) + private val removableMethodIds = MethodId.entries fun parseEndpoint(methodId: String, endpointData: String): Endpoint? { if (!methodIdPattern.matches(methodId)) return null @@ -170,7 +164,7 @@ class PublicPaykitRepo @Inject constructor( private suspend fun removePublishedEndpoints() { publishMutex.withLock { val currentMethodIds = currentPublishedMethodIds() - publishableMethodIds + removableMethodIds .filter { it.rawValue in currentMethodIds } .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } } @@ -186,7 +180,7 @@ class PublicPaykitRepo @Inject constructor( } val publishedMethodIds = currentPublishedMethodIds() - publishableMethodIds + removableMethodIds .filter { it.rawValue in publishedMethodIds && it.rawValue !in desiredMethodIds } .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index b6b85ad001..9cc2ec6d24 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast import to.bitkit.repositories.PubkyContactError import to.bitkit.repositories.PubkyRepo @@ -99,7 +100,11 @@ class AddContactViewModel @Inject constructor( private suspend fun loadPaymentEndpoint(publicKey: String): Boolean { return publicPaykitRepo.hasPayablePublicEndpoint(publicKey) .onFailure { - Logger.warn("Failed to load public Paykit endpoint for '$publicKey'", it, context = TAG) + Logger.warn( + "Failed to load public Paykit endpoint for '${PubkyPublicKeyFormat.redacted(publicKey)}'", + it, + context = TAG, + ) } .getOrDefault(false) } @@ -119,7 +124,12 @@ class AddContactViewModel @Inject constructor( } } .onFailure { - Logger.warn("Failed to begin public Paykit payment for '${profile.publicKey}'", it, context = TAG) + val redactedPublicKey = PubkyPublicKeyFormat.redacted(profile.publicKey) + Logger.warn( + "Failed to begin public Paykit payment for '$redactedPublicKey'", + it, + context = TAG, + ) showPayError(R.string.slashtags__error_pay_not_opened_msg) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt index c12f0e1be5..48f99efe41 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt @@ -82,7 +82,11 @@ class ContactActivityViewModel @Inject constructor( pubkyRepo.fetchContactProfile(publicKey) .onSuccess { profile -> _uiState.update { it.copy(profile = profile) } } .onFailure { - Logger.warn("Failed to load contact profile for '$publicKey'", it, context = TAG) + Logger.warn( + "Failed to load contact profile for '${PubkyPublicKeyFormat.redacted(publicKey)}'", + it, + context = TAG, + ) _uiState.update { it.copy(profile = PubkyProfile.placeholder(publicKey)) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index e1d77b78a7..ff0c805377 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -21,6 +21,7 @@ import to.bitkit.R import to.bitkit.ext.setClipboardText import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult @@ -45,6 +46,8 @@ class ContactDetailViewModel @Inject constructor( savedStateHandle["publicKey"], ) { "publicKey not found in SavedStateHandle" } + private val redactedPublicKey = PubkyPublicKeyFormat.redacted(publicKey) + private val _uiState = MutableStateFlow(ContactDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -98,7 +101,7 @@ class ContactDetailViewModel @Inject constructor( private suspend fun loadPaymentEndpoint(): Boolean { return publicPaykitRepo.hasPayablePublicEndpoint(publicKey) .onFailure { - Logger.warn("Failed to load public Paykit endpoint for '$publicKey'", it, context = TAG) + Logger.warn("Failed to load public Paykit endpoint for '$redactedPublicKey'", it, context = TAG) } .getOrDefault(false) } @@ -117,7 +120,7 @@ class ContactDetailViewModel @Inject constructor( } } .onFailure { - Logger.warn("Failed to begin public Paykit payment for '$publicKey'", it, context = TAG) + Logger.warn("Failed to begin public Paykit payment for '$redactedPublicKey'", it, context = TAG) showPayError(R.string.slashtags__error_pay_not_opened_msg) } } @@ -196,7 +199,7 @@ class ContactDetailViewModel @Inject constructor( _effects.emit(ContactDetailEffect.DeleteSuccess) } .onFailure { - Logger.error("Failed to delete contact '$publicKey'", it, context = TAG) + Logger.error("Failed to delete contact '$redactedPublicKey'", it, context = TAG) } } } @@ -212,7 +215,7 @@ class ContactDetailViewModel @Inject constructor( links = profile.links.map { PubkyProfileLink(it.label, it.url) }, tags = tags, ).onFailure { - Logger.error("Failed to update tags for contact '$publicKey'", it, context = TAG) + Logger.error("Failed to update tags for contact '$redactedPublicKey'", it, context = TAG) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt index 5c85e90b12..b7bb3709f7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsScreen.kt @@ -104,7 +104,7 @@ private fun Content( HorizontalSpacer(16.dp) Switch( checked = uiState.isPaymentSharingEnabled, - onCheckedChange = onPaymentSharingChange, + onCheckedChange = if (uiState.isLoading) null else onPaymentSharingChange, colors = SwitchDefaults.colors( checkedThumbColor = Colors.White, checkedTrackColor = Colors.PubkyGreen, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index e5cca3458a..0591262c85 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -106,8 +106,10 @@ fun ActivityRow( } stringResource(titleRes, it) } - val resolvedTitle = title ?: contactTitle var isCpfpChild by remember { mutableStateOf(false) } + val resolvedTitle = (title ?: contactTitle).takeIf { + shouldUseContactActivityTitle(item, status, isTransfer, isCpfpChild) + } LaunchedEffect(item) { isCpfpChild = if (item is Activity.Onchain && activityListViewModel != null) { @@ -194,6 +196,20 @@ private fun contactName(activity: Activity, contacts: List): Strin return contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, contact) }?.name } +private fun shouldUseContactActivityTitle( + activity: Activity, + status: PaymentState?, + isTransfer: Boolean, + isCpfpChild: Boolean, +): Boolean { + if (isTransfer || isCpfpChild) return false + + return when (activity) { + is Activity.Lightning -> status == PaymentState.SUCCEEDED + is Activity.Onchain -> activity.v1.doesExist + } +} + @Suppress("CyclomaticComplexMethod") @Composable private fun TransactionStatusText( diff --git a/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt b/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt index e07c1c551b..60efde08cb 100644 --- a/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt @@ -38,6 +38,13 @@ class PubkyPublicKeyFormatTest { ) } + @Test + fun `redacted shortens normalized pubky keys`() { + val rawKey = "ybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" + + assertEquals("pubkyyb…pqxot1u", PubkyPublicKeyFormat.redacted(rawKey)) + } + @Test fun `matches compares equivalent pubky representations`() { val rawKey = "ybndrfg8ejkmcpqxot1uwisza345h769ybndrfg8ejkmcpqxot1u" diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 17bc032fbb..77dad96fe5 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -45,6 +45,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { Result.success( listOf( paymentEntry(MethodId.Bolt11, "lnbc1old"), + paymentEntry(MethodId.Lnurl, "lnurl1obsolete"), paymentEntry(MethodId.P2pkh, "1obsolete"), ), ), @@ -57,12 +58,38 @@ class PublicPaykitRepoTest : BaseUnitTest() { verify(pubkyRepo).setPaymentEndpoint(MethodId.Bolt11.rawValue, """{"value":"lnbc1public"}""") verify(pubkyRepo).setPaymentEndpoint(MethodId.P2tr.rawValue, """{"value":"bc1ptest"}""") verify(pubkyRepo).getPaymentList("pubkyself") + verify(pubkyRepo).removePaymentEndpoint(MethodId.Lnurl.rawValue) verify(pubkyRepo).removePaymentEndpoint(MethodId.P2pkh.rawValue) } verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.Bolt11.rawValue) verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.P2tr.rawValue) } + @Test + fun `syncPublishedEndpoints removes supported endpoints including lnurl`() = test { + val pubkyRepo = mock() + val sut = createRepo(pubkyRepo = pubkyRepo) + + whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) + whenever(pubkyRepo.removePaymentEndpoint(any())).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( + Result.success( + listOf( + paymentEntry(MethodId.Bolt11, "lnbc1old"), + paymentEntry(MethodId.Lnurl, "lnurl1old"), + paymentEntry(MethodId.P2tr, "bc1pold"), + ), + ), + ) + + val result = sut.syncPublishedEndpoints(publish = false) + + assertTrue(result.isSuccess) + verify(pubkyRepo).removePaymentEndpoint(MethodId.Bolt11.rawValue) + verify(pubkyRepo).removePaymentEndpoint(MethodId.Lnurl.rawValue) + verify(pubkyRepo).removePaymentEndpoint(MethodId.P2tr.rawValue) + } + @Test fun `syncCurrentPublishedEndpoints returns SessionNotActive when no pubky session exists`() = test { val pubkyRepo = mock() From 6a67b2c77b5029e1e1a499c7d8e30b8cdd84d926 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 13:40:38 -0500 Subject: [PATCH 18/35] fix: follow repo rules --- CHANGELOG.md | 2 ++ .../wallets/activity/components/ActivityListGrouped.kt | 4 +--- .../screens/wallets/activity/components/ActivityRow.kt | 9 ++++----- changelog.d/next/906.fixed.md | 1 - changelog.d/next/920.fixed.md | 1 - 5 files changed, 7 insertions(+), 10 deletions(-) delete mode 100644 changelog.d/next/906.fixed.md delete mode 100644 changelog.d/next/920.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 43fec9fef9..db4df41e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve Pubky profile restore, contact editing, and contact routing flows #905 ### Fixed +- Fix probe results and add keysend probes #920 +- Align top bar back arrow and passphrase input cursor/placeholder with iOS #906 - Polish Terms of Use screen padding to match iOS #903 ## [2.2.0] - 2026-04-07 diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index bf6d7612d0..be53e358e2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -3,10 +3,8 @@ package to.bitkit.ui.screens.wallets.activity.components import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn @@ -121,7 +119,7 @@ fun ActivityListGrouped( } } item { - Spacer(modifier = Modifier.height(120.dp)) + VerticalSpacer(120.dp) } } } else { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 0591262c85..e1a2740700 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -5,10 +5,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -52,6 +50,7 @@ import to.bitkit.ui.blocktankViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.currencyViewModel import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems import to.bitkit.ui.settingsViewModel @@ -129,7 +128,7 @@ fun ActivityRow( .testTag(testTag) ) { ActivityIcon(activity = item, size = 40.dp, isCpfpChild = isCpfpChild) - Spacer(modifier = Modifier.width(16.dp)) + HorizontalSpacer(16.dp) Column( verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f) @@ -183,7 +182,7 @@ fun ActivityRow( maxLines = 1, ) } - Spacer(modifier = Modifier.width(16.dp)) + HorizontalSpacer(16.dp) AmountView( item = item, prefix = amountPrefix, @@ -332,7 +331,7 @@ private fun AmountViewContent( if (titleSymbol != null && !isSymbolSuffix) { BodyMSB(text = titleSymbol, color = Colors.White64) } - Spacer(modifier = Modifier.width(2.dp)) + HorizontalSpacer(2.dp) AnimatedContent( targetState = hideBalance, transitionSpec = { BalanceAnimations.activityAmountTransition }, diff --git a/changelog.d/next/906.fixed.md b/changelog.d/next/906.fixed.md deleted file mode 100644 index 4560600b96..0000000000 --- a/changelog.d/next/906.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Align top bar back arrow and passphrase input cursor and placeholder styling with iOS. diff --git a/changelog.d/next/920.fixed.md b/changelog.d/next/920.fixed.md deleted file mode 100644 index f4513dca67..0000000000 --- a/changelog.d/next/920.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fix probe results and add keysend probes. From 1a0d75193df3112762444e7eaea57ec9c0d6803a Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 14:25:44 -0500 Subject: [PATCH 19/35] fix: manage paykit endpoint lifecycle --- .../main/java/to/bitkit/data/SettingsStore.kt | 3 + .../commands/NotifyPaymentReceivedHandler.kt | 2 +- .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 2 +- .../java/to/bitkit/repositories/PubkyRepo.kt | 20 ++ .../bitkit/repositories/PublicPaykitRepo.kt | 116 ++++++++-- .../java/to/bitkit/repositories/WalletRepo.kt | 2 +- .../java/to/bitkit/services/CoreService.kt | 2 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 1 + .../main/java/to/bitkit/ui/NodeInfoScreen.kt | 2 +- .../settings/lightning/ChannelDetailScreen.kt | 2 +- .../lightning/LightningConnectionsScreen.kt | 2 +- .../to/bitkit/usecases/WipeWalletUseCase.kt | 3 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 36 +++- .../to/bitkit/repositories/PubkyRepoTest.kt | 41 ++++ .../repositories/PublicPaykitRepoTest.kt | 198 +++++++++++++++++- .../bitkit/usecases/WipeWalletUseCaseTest.kt | 22 +- 16 files changed, 405 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 146955dfb0..03888d46b6 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -102,6 +102,9 @@ data class SettingsData( val hasSeenContactsIntro: Boolean = false, val hasConfirmedPublicPaykitEndpoints: Boolean = false, val sharesPublicPaykitEndpoints: Boolean = false, + val publicPaykitBolt11: String = "", + val publicPaykitBolt11PaymentHash: String = "", + val publicPaykitBolt11ExpiresAtMillis: Long = 0, val quickPayIntroSeen: Boolean = false, val bgPaymentsIntroSeen: Boolean = false, val isQuickPayEnabled: Boolean = false, diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 1992e08cd0..629b848423 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -17,8 +17,8 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NotificationDetails import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay -import to.bitkit.repositories.ActivityRepo import to.bitkit.models.msatCeilOf +import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.CurrencyRepo import to.bitkit.utils.Logger import javax.inject.Inject diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index fe9a86efcb..6572848a8d 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -26,7 +26,6 @@ import to.bitkit.ext.amountOnClose import to.bitkit.ext.toUserMessage import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.msatCeilOf import to.bitkit.models.BlocktankNotificationType.cjitPaymentArrived import to.bitkit.models.BlocktankNotificationType.incomingHtlc import to.bitkit.models.BlocktankNotificationType.mutualClose @@ -36,6 +35,7 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NotificationDetails +import to.bitkit.models.msatCeilOf import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 147eb33b7c..73e4a8a957 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -299,6 +299,22 @@ class PubkyRepo @Inject constructor( } } + suspend fun removeBitkitPaymentEndpoints(): Result = withContext(ioDispatcher) { + runCatching { + val currentPublicKey = _publicKey.value ?: pubkyService.currentPublicKey()?.ensurePubkyPrefix() + ?: return@runCatching + val managedMethodIds = MethodId.entries + .filter { it.isBitkitManaged } + .map { it.rawValue } + .toSet() + + pubkyService.getPaymentList(currentPublicKey) + .map { it.methodId } + .filter { it in managedMethodIds } + .forEach { pubkyService.removePaymentEndpoint(it) } + } + } + suspend fun currentPublicKey(): Result = withContext(ioDispatcher) { runCatching { pubkyService.currentPublicKey()?.ensurePubkyPrefix() @@ -856,6 +872,10 @@ class PubkyRepo @Inject constructor( // region Sign out suspend fun signOut(): Result { + val cleanupResult = removeBitkitPaymentEndpoints() + .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } + if (cleanupResult.isFailure) return cleanupResult + val result = runCatching { withContext(ioDispatcher) { pubkyService.signOut() } }.recoverCatching { diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index c8c7c54305..ca1c4050b6 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -3,6 +3,7 @@ package to.bitkit.repositories import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.validateBitcoinAddress import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -10,8 +11,11 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env +import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.toLdkNetwork import to.bitkit.services.CoreService @@ -20,6 +24,10 @@ import to.bitkit.utils.NetworkValidationHelper import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime sealed class PublicPaykitError(message: String) : AppError(message) { data object InvalidPayload : PublicPaykitError("Invalid Paykit payment endpoint payload") @@ -34,6 +42,8 @@ sealed interface PublicPaykitPaymentResult { data object NotOpened : PublicPaykitPaymentResult } +@OptIn(ExperimentalTime::class) +@Suppress("LongParameterList") @Singleton class PublicPaykitRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @@ -41,6 +51,8 @@ class PublicPaykitRepo @Inject constructor( private val walletRepo: WalletRepo, private val lightningRepo: LightningRepo, private val coreService: CoreService, + private val settingsStore: SettingsStore, + private val clock: Clock, ) { companion object { private val methodIdPattern = Regex("^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$") @@ -55,7 +67,9 @@ class PublicPaykitRepo @Inject constructor( MethodId.P2pkh, ) - private val removableMethodIds = MethodId.entries + private val managedMethodIds = MethodId.entries.filter { it.isBitkitManaged } + private val publicBolt11Expiry = 24.hours + private val publicBolt11RefreshWindow = 30.minutes fun parseEndpoint(methodId: String, endpointData: String): Endpoint? { if (!methodIdPattern.matches(methodId)) return null @@ -150,6 +164,18 @@ class PublicPaykitRepo @Inject constructor( } } + suspend fun refreshPublishedBolt11ForPayment(paymentHash: String): Result = withContext(ioDispatcher) { + runCatching { + val settings = settingsStore.data.first() + if (!settings.sharesPublicPaykitEndpoints) return@runCatching + if (settings.publicPaykitBolt11PaymentHash != paymentHash) return@runCatching + + clearPublicBolt11Metadata() + val desired = buildWalletEndpoints(refresh = true) + applyPublishedEndpoints(desired) + } + } + private suspend fun fetchPublicEndpoints(publicKey: String): Result> = withContext(ioDispatcher) { runCatching { val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey @@ -164,9 +190,10 @@ class PublicPaykitRepo @Inject constructor( private suspend fun removePublishedEndpoints() { publishMutex.withLock { val currentMethodIds = currentPublishedMethodIds() - removableMethodIds + managedMethodIds .filter { it.rawValue in currentMethodIds } .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } + clearPublicBolt11Metadata() } } @@ -180,7 +207,7 @@ class PublicPaykitRepo @Inject constructor( } val publishedMethodIds = currentPublishedMethodIds() - removableMethodIds + managedMethodIds .filter { it.rawValue in publishedMethodIds && it.rawValue !in desiredMethodIds } .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } } @@ -211,15 +238,7 @@ class PublicPaykitRepo @Inject constructor( val state = walletRepo.walletState.value val endpoints = mutableListOf() - val publicBolt11 = buildPublicBolt11() - - if (publicBolt11.isNotBlank()) { - endpoints += Endpoint( - methodId = MethodId.Bolt11, - value = publicBolt11, - rawPayload = serializePayload(publicBolt11), - ) - } + buildPublicBolt11Endpoint()?.let { endpoints += it } val onchainAddress = state.onchainAddress if (onchainAddress.isNotBlank()) { @@ -236,10 +255,61 @@ class PublicPaykitRepo @Inject constructor( return endpoints } - private suspend fun buildPublicBolt11(): String { - if (!lightningRepo.canReceive()) return "" + private suspend fun buildPublicBolt11Endpoint(): Endpoint? { + if (!lightningRepo.canReceive()) { + clearPublicBolt11Metadata() + return null + } + + val settings = settingsStore.data.first() + val cachedBolt11 = settings.publicPaykitBolt11 + if (cachedBolt11.isNotBlank() && !settings.shouldRefreshPublicBolt11(clock.now().toEpochMilliseconds())) { + return Endpoint( + methodId = MethodId.Bolt11, + value = cachedBolt11, + rawPayload = serializePayload(cachedBolt11), + ) + } + + val bolt11 = lightningRepo.createInvoice( + amountSats = null, + description = "", + expirySeconds = publicBolt11Expiry.inWholeSeconds.toUInt(), + ).getOrThrow() + val invoice = (coreService.decode(bolt11) as Scanner.Lightning).invoice + val expiresAtMillis = clock.now().plus(publicBolt11Expiry).toEpochMilliseconds() + + settingsStore.update { + it.copy( + publicPaykitBolt11 = bolt11, + publicPaykitBolt11PaymentHash = invoice.paymentHash.toHex(), + publicPaykitBolt11ExpiresAtMillis = expiresAtMillis, + ) + } + + return Endpoint( + methodId = MethodId.Bolt11, + value = bolt11, + rawPayload = serializePayload(bolt11), + ) + } + + private suspend fun clearPublicBolt11Metadata() { + settingsStore.update { + it.copy( + publicPaykitBolt11 = "", + publicPaykitBolt11PaymentHash = "", + publicPaykitBolt11ExpiresAtMillis = 0, + ) + } + } + + private fun SettingsData.shouldRefreshPublicBolt11(nowMillis: Long): Boolean { + if (publicPaykitBolt11PaymentHash.isBlank()) return true + if (publicPaykitBolt11ExpiresAtMillis <= 0) return true - return lightningRepo.createInvoice(amountSats = null, description = "").getOrThrow() + val refreshAtMillis = publicPaykitBolt11ExpiresAtMillis - publicBolt11RefreshWindow.inWholeMilliseconds + return nowMillis >= refreshAtMillis } private suspend fun isPayable(endpoint: Endpoint): Boolean = runCatching { @@ -274,13 +344,17 @@ data class Endpoint( get() = value } -enum class MethodId(val rawValue: String, val isOnchain: Boolean = false) { - Bolt11("btc-lightning-bolt11"), +enum class MethodId( + val rawValue: String, + val isOnchain: Boolean = false, + val isBitkitManaged: Boolean = false, +) { + Bolt11("btc-lightning-bolt11", isBitkitManaged = true), Lnurl("btc-lightning-lnurl"), - P2tr("btc-bitcoin-p2tr", isOnchain = true), - P2wpkh("btc-bitcoin-p2wpkh", isOnchain = true), - P2sh("btc-bitcoin-p2sh", isOnchain = true), - P2pkh("btc-bitcoin-p2pkh", isOnchain = true), + P2tr("btc-bitcoin-p2tr", isOnchain = true, isBitkitManaged = true), + P2wpkh("btc-bitcoin-p2wpkh", isOnchain = true, isBitkitManaged = true), + P2sh("btc-bitcoin-p2sh", isOnchain = true, isBitkitManaged = true), + P2pkh("btc-bitcoin-p2pkh", isOnchain = true, isBitkitManaged = true), ; companion object { diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 199e2ff3e5..dde2ee0a44 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -32,8 +32,8 @@ import to.bitkit.ext.toHex import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState -import to.bitkit.models.msatFloorOf import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING +import to.bitkit.models.msatFloorOf import to.bitkit.models.toDerivationPath import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 4349eced4a..527dff93d3 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -72,11 +72,11 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.ext.amountSats -import to.bitkit.models.msatFloorOf import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid import to.bitkit.models.addressTypeFromAddress +import to.bitkit.models.msatFloorOf import to.bitkit.models.toCoreNetwork import to.bitkit.utils.AppError import to.bitkit.utils.Logger diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 57117c6c4f..262b99f512 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -254,6 +254,7 @@ fun ContentView( currencyViewModel.triggerRefresh() blocktankViewModel.refreshOrders() + appViewModel.refreshPublicPaykitEndpoints() } Lifecycle.Event.ON_STOP -> { diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 1b6f708408..43b87cd914 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -50,11 +50,11 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatToString import to.bitkit.ext.uri -import to.bitkit.models.msatFloorOf import to.bitkit.models.NodeLifecycleState import to.bitkit.models.NodePeer import to.bitkit.models.alias import to.bitkit.models.formatToModernDisplay +import to.bitkit.models.msatFloorOf import to.bitkit.repositories.LightningState import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 0b93407aca..a41243741d 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -60,8 +60,8 @@ import to.bitkit.ext.DatePattern import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails import to.bitkit.ext.setClipboardText -import to.bitkit.models.msatFloorOf import to.bitkit.models.Toast +import to.bitkit.models.msatFloorOf import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel import to.bitkit.ui.components.Caption13Up diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index 89f42eb0f0..4c19139542 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -47,8 +47,8 @@ import kotlinx.collections.immutable.toImmutableList import to.bitkit.R import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails -import to.bitkit.models.msatFloorOf import to.bitkit.models.formatToModernDisplay +import to.bitkit.models.msatFloorOf import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 47294aef0f..e3d03dff18 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -44,8 +44,9 @@ class WipeWalletUseCase @Inject constructor( backupRepo.setWiping(true) backupRepo.reset() - keychain.wipe() + pubkyRepo.removeBitkitPaymentEndpoints().getOrThrow() pubkyRepo.wipeLocalState() + keychain.wipe() firebaseMessaging.deleteToken() coreService.wipeData() diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 57983be169..36a39b4643 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -375,22 +375,28 @@ class AppViewModel @Inject constructor( private fun observePublicPaykitEndpoints() { viewModelScope.launch { walletRepo.walletState - .map { it.bolt11 to it.onchainAddress } + .map { it.onchainAddress } .distinctUntilChanged() .debounce(PUBLIC_PAYKIT_SYNC_DEBOUNCE) - .collect { (bolt11, onchainAddress) -> - val shouldPublish = settingsStore.data.first().sharesPublicPaykitEndpoints - if (!shouldPublish) return@collect - if (bolt11.isBlank() && onchainAddress.isBlank()) return@collect - - publicPaykitRepo.syncCurrentPublishedEndpoints() - .onFailure { - Logger.warn("Failed to refresh public Paykit endpoints", it, context = TAG) - } - } + .collect { refreshPublicPaykitEndpointsIfEnabled() } } } + fun refreshPublicPaykitEndpoints() { + viewModelScope.launch { refreshPublicPaykitEndpointsIfEnabled() } + } + + private suspend fun refreshPublicPaykitEndpointsIfEnabled() { + val shouldPublish = settingsStore.data.first().sharesPublicPaykitEndpoints + if (!shouldPublish) return + + val onchainAddress = walletRepo.walletState.value.onchainAddress + if (onchainAddress.isBlank() && !lightningRepo.canReceive()) return + + publicPaykitRepo.syncCurrentPublishedEndpoints() + .onFailure { Logger.warn("Failed to refresh public Paykit endpoints", it, context = TAG) } + } + @Suppress("CyclomaticComplexMethod") private fun handleLdkEvent(event: Event) { if (!walletRepo.walletExists()) return @@ -677,6 +683,14 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentReceived(event: Event.PaymentReceived) { event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) + publicPaykitRepo.refreshPublishedBolt11ForPayment(paymentHash) + .onFailure { + Logger.warn( + "Failed to refresh public Paykit invoice for '$paymentHash'", + it, + context = TAG, + ) + } } notifyPaymentReceived(event) } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 6a30ade135..d27b4f6d87 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -4,6 +4,7 @@ import app.cash.turbine.test import coil3.ImageLoader import coil3.disk.DiskCache import coil3.memory.MemoryCache +import com.synonym.paykit.FfiPaymentEntry import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Before @@ -246,6 +247,40 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyStore) { reset() } } + @Test + fun `removeBitkitPaymentEndpoints removes only bitkit managed endpoints`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.getPaymentList(VALID_SELF_KEY)).thenReturn( + listOf( + paymentEntry(MethodId.Bolt11), + paymentEntry(MethodId.Lnurl), + paymentEntry(MethodId.P2tr), + ), + ) + + val result = sut.removeBitkitPaymentEndpoints() + + assertTrue(result.isSuccess) + verifyBlocking(pubkyService) { removePaymentEndpoint(MethodId.Bolt11.rawValue) } + verifyBlocking(pubkyService) { removePaymentEndpoint(MethodId.P2tr.rawValue) } + verifyBlocking(pubkyService, never()) { removePaymentEndpoint(MethodId.Lnurl.rawValue) } + } + + @Test + fun `signOut should keep state when endpoint cleanup fails`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.getPaymentList(VALID_SELF_KEY)).thenAnswer { throw TestAppError("Cleanup failed") } + + val result = sut.signOut() + + assertTrue(result.isFailure) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertTrue(sut.isAuthenticated.value) + verifyBlocking(pubkyService, never()) { signOut() } + verifyBlocking(pubkyService, never()) { forceSignOut() } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } + } + @Test fun `signOut should evict pubky images from caches`() = test { authenticateForTesting() @@ -922,10 +957,16 @@ class PubkyRepoTest : BaseUnitTest() { whenever { pubkyService.getProfile(prefixedPublicKey) }.thenReturn(ffiProfile) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(secret) whenever { pubkyService.sessionList(secret, Env.contactsBasePath) }.thenReturn(emptyList()) + whenever { pubkyService.getPaymentList(prefixedPublicKey) }.thenReturn(emptyList()) sut.completeAuthentication() } + private fun paymentEntry(methodId: MethodId) = FfiPaymentEntry( + methodId = methodId.rawValue, + endpointData = """{"value":"value"}""", + ) + private fun createFfiProfile(name: String): CorePubkyProfile { val ffiProfile = mock() whenever(ffiProfile.name).thenReturn(name) diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 77dad96fe5..a70a2e359b 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -1,5 +1,8 @@ package to.bitkit.repositories +import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.Scanner import com.synonym.paykit.FfiPaymentEntry import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Test @@ -9,23 +12,38 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +@OptIn(ExperimentalTime::class) class PublicPaykitRepoTest : BaseUnitTest() { + companion object { + private const val NOW_MILLIS = 1_000L + } + @Test - fun `syncCurrentPublishedEndpoints sets desired endpoints and removes obsolete endpoints`() = test { + fun `syncCurrentPublishedEndpoints sets desired endpoints and removes obsolete bitkit endpoints`() = test { val pubkyRepo = mock() val walletRepo = mock() val lightningRepo = mock() + val coreService = mock() + val (settingsStore, settingsFlow) = createSettingsStore() val sut = createRepo( pubkyRepo = pubkyRepo, walletRepo = walletRepo, lightningRepo = lightningRepo, + coreService = coreService, + settingsStore = settingsStore, ) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) @@ -38,14 +56,14 @@ class PublicPaykitRepoTest : BaseUnitTest() { ), ) whenever(lightningRepo.canReceive()).thenReturn(true) - whenever(lightningRepo.createInvoice(null, "")).thenReturn(Result.success("lnbc1public")) + stubPublicInvoice(lightningRepo, coreService, "lnbc1public", byteArrayOf(1, 2, 3)) whenever(pubkyRepo.setPaymentEndpoint(any(), any())).thenReturn(Result.success(Unit)) whenever(pubkyRepo.removePaymentEndpoint(any())).thenReturn(Result.success(Unit)) whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( Result.success( listOf( paymentEntry(MethodId.Bolt11, "lnbc1old"), - paymentEntry(MethodId.Lnurl, "lnurl1obsolete"), + paymentEntry(MethodId.Lnurl, "lnurl1external"), paymentEntry(MethodId.P2pkh, "1obsolete"), ), ), @@ -54,21 +72,30 @@ class PublicPaykitRepoTest : BaseUnitTest() { val result = sut.syncCurrentPublishedEndpoints() assertTrue(result.isSuccess) + assertEquals("lnbc1public", settingsFlow.value.publicPaykitBolt11) + assertEquals("010203", settingsFlow.value.publicPaykitBolt11PaymentHash) inOrder(pubkyRepo) { verify(pubkyRepo).setPaymentEndpoint(MethodId.Bolt11.rawValue, """{"value":"lnbc1public"}""") verify(pubkyRepo).setPaymentEndpoint(MethodId.P2tr.rawValue, """{"value":"bc1ptest"}""") verify(pubkyRepo).getPaymentList("pubkyself") - verify(pubkyRepo).removePaymentEndpoint(MethodId.Lnurl.rawValue) verify(pubkyRepo).removePaymentEndpoint(MethodId.P2pkh.rawValue) } + verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.Lnurl.rawValue) verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.Bolt11.rawValue) verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.P2tr.rawValue) } @Test - fun `syncPublishedEndpoints removes supported endpoints including lnurl`() = test { + fun `syncPublishedEndpoints removes bitkit managed endpoints and preserves lnurl`() = test { val pubkyRepo = mock() - val sut = createRepo(pubkyRepo = pubkyRepo) + val (settingsStore, settingsFlow) = createSettingsStore( + SettingsData( + publicPaykitBolt11 = "lnbc1old", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + ), + ) + val sut = createRepo(pubkyRepo = pubkyRepo, settingsStore = settingsStore) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) whenever(pubkyRepo.removePaymentEndpoint(any())).thenReturn(Result.success(Unit)) @@ -76,7 +103,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { Result.success( listOf( paymentEntry(MethodId.Bolt11, "lnbc1old"), - paymentEntry(MethodId.Lnurl, "lnurl1old"), + paymentEntry(MethodId.Lnurl, "lnurl1external"), paymentEntry(MethodId.P2tr, "bc1pold"), ), ), @@ -85,9 +112,113 @@ class PublicPaykitRepoTest : BaseUnitTest() { val result = sut.syncPublishedEndpoints(publish = false) assertTrue(result.isSuccess) + assertEquals("", settingsFlow.value.publicPaykitBolt11) verify(pubkyRepo).removePaymentEndpoint(MethodId.Bolt11.rawValue) - verify(pubkyRepo).removePaymentEndpoint(MethodId.Lnurl.rawValue) verify(pubkyRepo).removePaymentEndpoint(MethodId.P2tr.rawValue) + verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.Lnurl.rawValue) + } + + @Test + fun `syncCurrentPublishedEndpoints reuses fresh public bolt11`() = test { + val pubkyRepo = mock() + val walletRepo = mock() + val lightningRepo = mock() + val coreService = mock() + val clock = createClock() + val (settingsStore) = createSettingsStore( + SettingsData( + publicPaykitBolt11 = "lnbc1cached", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + ), + ) + val sut = createRepo( + pubkyRepo = pubkyRepo, + walletRepo = walletRepo, + lightningRepo = lightningRepo, + coreService = coreService, + settingsStore = settingsStore, + clock = clock, + ) + + whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) + whenever(walletRepo.walletState).thenReturn(MutableStateFlow(WalletState())) + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(pubkyRepo.setPaymentEndpoint(any(), any())).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn(Result.success(emptyList())) + + val result = sut.syncCurrentPublishedEndpoints() + + assertTrue(result.isSuccess) + verify(lightningRepo, never()).createInvoice(amountSats = null, description = "", expirySeconds = 86_400u) + verify(coreService, never()).decode(any()) + verify(pubkyRepo).setPaymentEndpoint(MethodId.Bolt11.rawValue, """{"value":"lnbc1cached"}""") + } + + @Test + fun `refreshPublishedBolt11ForPayment rotates paid public bolt11`() = test { + val pubkyRepo = mock() + val walletRepo = mock() + val lightningRepo = mock() + val coreService = mock() + val (settingsStore, settingsFlow) = createSettingsStore( + SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitBolt11 = "lnbc1old", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + ), + ) + val sut = createRepo( + pubkyRepo = pubkyRepo, + walletRepo = walletRepo, + lightningRepo = lightningRepo, + coreService = coreService, + settingsStore = settingsStore, + ) + + whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) + whenever(walletRepo.walletState).thenReturn(MutableStateFlow(WalletState())) + whenever(lightningRepo.canReceive()).thenReturn(true) + stubPublicInvoice(lightningRepo, coreService, "lnbc1new", byteArrayOf(4, 5, 6)) + whenever(pubkyRepo.setPaymentEndpoint(any(), any())).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( + Result.success(listOf(paymentEntry(MethodId.Bolt11, "lnbc1old"))), + ) + + val result = sut.refreshPublishedBolt11ForPayment("010203") + + assertTrue(result.isSuccess) + assertEquals("lnbc1new", settingsFlow.value.publicPaykitBolt11) + assertEquals("040506", settingsFlow.value.publicPaykitBolt11PaymentHash) + verify(pubkyRepo).setPaymentEndpoint(MethodId.Bolt11.rawValue, """{"value":"lnbc1new"}""") + } + + @Test + fun `refreshPublishedBolt11ForPayment ignores unrelated payment hash`() = test { + val pubkyRepo = mock() + val lightningRepo = mock() + val coreService = mock() + val (settingsStore) = createSettingsStore( + SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitBolt11 = "lnbc1old", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + ), + ) + val sut = createRepo( + pubkyRepo = pubkyRepo, + lightningRepo = lightningRepo, + coreService = coreService, + settingsStore = settingsStore, + ) + + val result = sut.refreshPublishedBolt11ForPayment("unrelated") + + assertTrue(result.isSuccess) + verify(lightningRepo, never()).createInvoice(amountSats = null, description = "", expirySeconds = 86_400u) + verify(pubkyRepo, never()).setPaymentEndpoint(any(), any()) } @Test @@ -252,21 +383,72 @@ class PublicPaykitRepoTest : BaseUnitTest() { rawPayload = """{"value":"$value"}""", ) + @Suppress("LongParameterList") private fun createRepo( pubkyRepo: PubkyRepo = mock(), walletRepo: WalletRepo = mock(), lightningRepo: LightningRepo = mock(), coreService: CoreService = mock(), + settingsStore: SettingsStore = createSettingsStore().first, + clock: Clock = createClock(), ) = PublicPaykitRepo( ioDispatcher = testDispatcher, pubkyRepo = pubkyRepo, walletRepo = walletRepo, lightningRepo = lightningRepo, coreService = coreService, + settingsStore = settingsStore, + clock = clock, ) private fun paymentEntry(methodId: MethodId, value: String) = FfiPaymentEntry( methodId = methodId.rawValue, endpointData = """{"value":"$value"}""", ) + + private fun createSettingsStore( + initial: SettingsData = SettingsData(), + ): Pair> { + val settingsStore = mock() + val flow = MutableStateFlow(initial) + whenever(settingsStore.data).thenReturn(flow) + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + flow.value = transform(flow.value) + Unit + } + return settingsStore to flow + } + + private fun createClock(): Clock { + val clock = mock() + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MILLIS)) + return clock + } + + private fun freshExpiryMillis() = NOW_MILLIS + 1.hours.inWholeMilliseconds + + private suspend fun stubPublicInvoice( + lightningRepo: LightningRepo, + coreService: CoreService, + bolt11: String, + paymentHash: ByteArray, + ) { + whenever( + lightningRepo.createInvoice(amountSats = null, description = "", expirySeconds = 86_400u) + ).thenReturn(Result.success(bolt11)) + whenever(coreService.decode(bolt11)).thenReturn(Scanner.Lightning(lightningInvoice(bolt11, paymentHash))) + } + + private fun lightningInvoice(bolt11: String, paymentHash: ByteArray) = LightningInvoice( + bolt11 = bolt11, + paymentHash = paymentHash, + amountSatoshis = 0u, + timestampSeconds = 0u, + expirySeconds = 86_400u, + isExpired = false, + description = "", + networkType = NetworkType.REGTEST, + payeeNodeId = null, + ) } diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 2e50351bf6..39da263e32 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -6,6 +6,7 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.AppDb @@ -47,6 +48,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { @Before fun setUp() { whenever { lightningRepo.wipeStorage(0) }.thenReturn(Result.success(Unit)) + whenever { pubkyRepo.removeBitkitPaymentEndpoints() }.thenReturn(Result.success(Unit)) onWipeCalled = false onSetWalletExistsStateCalled = false @@ -90,8 +92,9 @@ class WipeWalletUseCaseTest : BaseUnitTest() { ) inOrder.verify(backupRepo).setWiping(true) inOrder.verify(backupRepo).reset() - inOrder.verify(keychain).wipe() + inOrder.verify(pubkyRepo).removeBitkitPaymentEndpoints() inOrder.verify(pubkyRepo).wipeLocalState() + inOrder.verify(keychain).wipe() inOrder.verify(coreService).wipeData() inOrder.verify(db).clearAllTables() inOrder.verify(settingsStore).reset() @@ -134,6 +137,23 @@ class WipeWalletUseCaseTest : BaseUnitTest() { verify(backupRepo).setWiping(false) } + @Test + fun `invoke should return failure when endpoint cleanup fails`() = runTest { + whenever { pubkyRepo.removeBitkitPaymentEndpoints() }.thenReturn( + Result.failure(RuntimeException("Cleanup failed")), + ) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isFailure) + verify(keychain, never()).wipe() + verify(pubkyRepo, never()).wipeLocalState() + verify(backupRepo).setWiping(false) + } + @Test fun `invoke should return failure when lightningRepo wipeStorage fails`() = runTest { val error = RuntimeException("Lightning wipe failed") From 7b030f1afa859584fc0c8e15487915fe3a8a030d Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 14:48:45 -0500 Subject: [PATCH 20/35] fix: allow cleanup failure --- .../main/java/to/bitkit/repositories/PubkyRepo.kt | 3 +-- .../java/to/bitkit/usecases/WipeWalletUseCase.kt | 3 ++- .../java/to/bitkit/repositories/PubkyRepoTest.kt | 13 ++++++------- .../to/bitkit/usecases/WipeWalletUseCaseTest.kt | 9 ++++----- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 73e4a8a957..0bd2e2167c 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -872,9 +872,8 @@ class PubkyRepo @Inject constructor( // region Sign out suspend fun signOut(): Result { - val cleanupResult = removeBitkitPaymentEndpoints() + removeBitkitPaymentEndpoints() .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } - if (cleanupResult.isFailure) return cleanupResult val result = runCatching { withContext(ioDispatcher) { pubkyService.signOut() } diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index e3d03dff18..ca1f462ec5 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -44,7 +44,8 @@ class WipeWalletUseCase @Inject constructor( backupRepo.setWiping(true) backupRepo.reset() - pubkyRepo.removeBitkitPaymentEndpoints().getOrThrow() + pubkyRepo.removeBitkitPaymentEndpoints() + .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } pubkyRepo.wipeLocalState() keychain.wipe() firebaseMessaging.deleteToken() diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index d27b4f6d87..2ae9e490f9 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -267,18 +267,17 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `signOut should keep state when endpoint cleanup fails`() = test { + fun `signOut should continue when endpoint cleanup fails`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) whenever(pubkyService.getPaymentList(VALID_SELF_KEY)).thenAnswer { throw TestAppError("Cleanup failed") } val result = sut.signOut() - assertTrue(result.isFailure) - assertEquals(VALID_SELF_KEY, sut.publicKey.value) - assertTrue(sut.isAuthenticated.value) - verifyBlocking(pubkyService, never()) { signOut() } - verifyBlocking(pubkyService, never()) { forceSignOut() } - verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } + assertTrue(result.isSuccess) + assertNull(sut.publicKey.value) + assertFalse(sut.isAuthenticated.value) + verifyBlocking(pubkyService) { signOut() } + verifyBlocking(keychain, atLeastOnce()) { delete(Keychain.Key.PAYKIT_SESSION.name) } } @Test diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 39da263e32..d13ac24a5b 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -6,7 +6,6 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.AppDb @@ -138,7 +137,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { } @Test - fun `invoke should return failure when endpoint cleanup fails`() = runTest { + fun `invoke should continue when endpoint cleanup fails`() = runTest { whenever { pubkyRepo.removeBitkitPaymentEndpoints() }.thenReturn( Result.failure(RuntimeException("Cleanup failed")), ) @@ -148,9 +147,9 @@ class WipeWalletUseCaseTest : BaseUnitTest() { onSuccess = { onSetWalletExistsStateCalled = true }, ) - assertTrue(result.isFailure) - verify(keychain, never()).wipe() - verify(pubkyRepo, never()).wipeLocalState() + assertTrue(result.isSuccess) + verify(pubkyRepo).wipeLocalState() + verify(keychain).wipe() verify(backupRepo).setWiping(false) } From de2cee763442cad5143f480b5497ccc568524fee Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 15:00:37 -0500 Subject: [PATCH 21/35] fix: tag pending contact payments --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../viewmodels/AppViewModelSendFlowTest.kt | 112 ++++++++++++------ 2 files changed, 78 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 36a39b4643..f6e5ad0c3a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -699,6 +699,7 @@ class AppViewModel @Inject constructor( event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { + syncContactForActivity(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Success(paymentHash)) if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentSucceeded() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index cfaa481b7d..53a60d9a29 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9,9 +9,11 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.Event import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore @@ -31,6 +33,7 @@ import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo @@ -79,18 +82,25 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val formatMoneyValue = mock() private val balanceState = MutableStateFlow(BalanceState()) + private val nodeEvents = MutableSharedFlow() private val timedSheetManager = mock() @Before fun setUp() { + stubRepositories() + sut = createViewModel() + } + + private fun stubRepositories() { whenever(context.getString(any())).thenReturn("") whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.CONNECTED)) whenever(healthRepo.healthState).thenReturn(MutableStateFlow(mock())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) - whenever(lightningRepo.nodeEvents).thenReturn(MutableSharedFlow()) + whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(walletRepo.walletState).thenReturn(MutableStateFlow(WalletState())) + whenever(walletRepo.walletExists()).thenReturn(true) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) @@ -108,42 +118,42 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) } .thenReturn(Result.success(2u)) whenever { lightningRepo.canSend(any(), any()) }.thenReturn(true) - - sut = AppViewModel( - connectivityRepo = connectivityRepo, - healthRepo = healthRepo, - toastManagerProvider = { mock() }, - timedSheetManagerProvider = { timedSheetManager }, - context = context, - bgDispatcher = testDispatcher, - keychain = keychain, - lightningRepo = lightningRepo, - pendingPaymentRepo = pendingPaymentRepo, - walletRepo = walletRepo, - backupRepo = backupRepo, - settingsStore = settingsStore, - currencyRepo = currencyRepo, - activityRepo = activityRepo, - preActivityMetadataRepo = preActivityMetadataRepo, - blocktankRepo = blocktankRepo, - appUpdaterService = appUpdaterService, - notifyPaymentReceivedHandler = notifyPaymentReceivedHandler, - cacheStore = cacheStore, - transferRepo = transferRepo, - migrationService = migrationService, - coreService = coreService, - publicPaykitRepo = publicPaykitRepo, - appUpdateSheet = mock(), - backupSheet = mock(), - notificationsSheet = mock(), - quickPaySheet = mock(), - highBalanceSheet = mock(), - formatMoneyValue = formatMoneyValue, - widgetsRepo = widgetsRepo, - pubkyRepo = pubkyRepo, - ) } + private fun createViewModel() = AppViewModel( + connectivityRepo = connectivityRepo, + healthRepo = healthRepo, + toastManagerProvider = { mock() }, + timedSheetManagerProvider = { timedSheetManager }, + context = context, + bgDispatcher = testDispatcher, + keychain = keychain, + lightningRepo = lightningRepo, + pendingPaymentRepo = pendingPaymentRepo, + walletRepo = walletRepo, + backupRepo = backupRepo, + settingsStore = settingsStore, + currencyRepo = currencyRepo, + activityRepo = activityRepo, + preActivityMetadataRepo = preActivityMetadataRepo, + blocktankRepo = blocktankRepo, + appUpdaterService = appUpdaterService, + notifyPaymentReceivedHandler = notifyPaymentReceivedHandler, + cacheStore = cacheStore, + transferRepo = transferRepo, + migrationService = migrationService, + coreService = coreService, + publicPaykitRepo = publicPaykitRepo, + appUpdateSheet = mock(), + backupSheet = mock(), + notificationsSheet = mock(), + quickPaySheet = mock(), + highBalanceSheet = mock(), + formatMoneyValue = formatMoneyValue, + widgetsRepo = widgetsRepo, + pubkyRepo = pubkyRepo, + ) + @Test fun `canSwitchWallet is false when not unified`() = test { sut.setSendEvent(SendEvent.AmountChange(1000u)) @@ -254,6 +264,30 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(before, sut.sendUiState.value.payMethod) } + @Test + fun `pending contact lightning success tags activity`() = test { + val contactKey = "pubkycontact" + val paymentHash = "pending_hash" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + whenever(activityRepo.setContact(contactKey, paymentHash)).thenReturn(Result.success(Unit)) + advanceUntilIdle() + + setPendingContactPaymentContext(paymentHash, contactKey) + nodeEvents.emit( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) + verify(activityRepo).setContact(contactPublicKey = contactKey, forPaymentId = paymentHash) + } + @Test fun `amount change clears confirmedWarnings`() = test { setUnifiedState(amount = 1000u) @@ -307,6 +341,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(0L, sut.sendUiState.value.lastLightningFee) } + @Suppress("UNCHECKED_CAST") + private fun setPendingContactPaymentContext(paymentHash: String, publicKey: String) { + val field = AppViewModel::class.java.getDeclaredField("pendingContactPaymentContexts") + field.isAccessible = true + val contexts = field.get(sut) as MutableMap + contexts[paymentHash] = ContactPaymentContext(publicKey) + } + @Suppress("UNCHECKED_CAST") private fun setSendState(state: SendUiState) { val field = AppViewModel::class.java.getDeclaredField("_sendUiState") From 9a25b62edc992a48bd9d0baf3ab822f3cf0ccda9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 15:19:40 -0500 Subject: [PATCH 22/35] fix: harden public contact payments --- .../java/to/bitkit/ui/sheets/SendSheet.kt | 2 ++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 29 +++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 30 +++++++++++++++++++ scripts/collect-changelog.sh | 7 +++-- scripts/preview-changelog.sh | 7 +++-- 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 9b9431d65f..bdfef1bf5f 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -276,11 +276,13 @@ fun SendSheet( ) }, onPaymentPending = { paymentHash, amount -> + appViewModel.preserveContactPaymentContext(paymentHash) navController.navigateTo(SendRoute.Pending(paymentHash, amount)) { popUpTo(startDestination) { inclusive = true } } }, onShowError = { errorMessage -> + appViewModel.clearActiveContactPaymentContext() navController.navigateTo(SendRoute.Error(errorMessage)) } ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f6e5ad0c3a..f06f8f493b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -46,6 +46,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first @@ -153,6 +154,7 @@ import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @@ -335,6 +337,7 @@ class AppViewModel @Inject constructor( } observeLdkNodeEvents() observePublicPaykitEndpoints() + observePublicPaykitInvoiceExpiry() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -382,6 +385,22 @@ class AppViewModel @Inject constructor( } } + private fun observePublicPaykitInvoiceExpiry() { + viewModelScope.launch { + settingsStore.data + .map { it.sharesPublicPaykitEndpoints to it.publicPaykitBolt11ExpiresAtMillis } + .distinctUntilChanged() + .collectLatest { (sharesPublicPaykitEndpoints, expiresAtMillis) -> + if (!sharesPublicPaykitEndpoints || expiresAtMillis <= 0) return@collectLatest + + val refreshAtMillis = expiresAtMillis - PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW.inWholeMilliseconds + val delayMillis = (refreshAtMillis - System.currentTimeMillis()).coerceAtLeast(0) + delay(delayMillis.milliseconds) + refreshPublicPaykitEndpointsIfEnabled() + } + } + } + fun refreshPublicPaykitEndpoints() { viewModelScope.launch { refreshPublicPaykitEndpointsIfEnabled() } } @@ -670,6 +689,7 @@ class AppViewModel @Inject constructor( event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { + clearPendingContactPaymentContext(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash)) if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentFailed() @@ -1437,12 +1457,18 @@ class AppViewModel @Inject constructor( action() } - private fun clearActiveContactPaymentContext() { + fun clearActiveContactPaymentContext() { synchronized(contactPaymentContextLock) { activeContactPaymentContext = null } } + private fun clearPendingContactPaymentContext(paymentHash: String) { + synchronized(contactPaymentContextLock) { + pendingContactPaymentContexts.remove(paymentHash) + } + } + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") private suspend fun onScanOnchain(invoice: OnChainInvoice, scanResult: String) { val validatedAddress = runCatching { validateBitcoinAddress(invoice.address) } @@ -2722,6 +2748,7 @@ class AppViewModel @Inject constructor( private const val AUTH_CHECK_SPLASH_DELAY_MS = 500L private const val ADDRESS_VALIDATION_DEBOUNCE_MS = 1000L private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds + private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val PUBKYAUTH_SCHEME = "pubkyauth" } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 53a60d9a29..6673e080f3 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -50,6 +50,7 @@ import to.bitkit.usecases.FormatMoneyValue import to.bitkit.utils.timedsheets.TimedSheetManager import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -288,6 +289,27 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(activityRepo).setContact(contactPublicKey = contactKey, forPaymentId = paymentHash) } + @Test + fun `pending contact lightning failure clears context`() = test { + val paymentHash = "pending_hash" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + advanceUntilIdle() + + setPendingContactPaymentContext(paymentHash, "pubkycontact") + nodeEvents.emit( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = paymentHash, + reason = null, + ), + ) + advanceUntilIdle() + + verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Failure(paymentHash)) + assertNull(pendingContactPaymentContext(paymentHash)) + } + @Test fun `amount change clears confirmedWarnings`() = test { setUnifiedState(amount = 1000u) @@ -349,6 +371,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { contexts[paymentHash] = ContactPaymentContext(publicKey) } + @Suppress("UNCHECKED_CAST") + private fun pendingContactPaymentContext(paymentHash: String): ContactPaymentContext? { + val field = AppViewModel::class.java.getDeclaredField("pendingContactPaymentContexts") + field.isAccessible = true + val contexts = field.get(sut) as MutableMap + return contexts[paymentHash] + } + @Suppress("UNCHECKED_CAST") private fun setSendState(state: SendUiState) { val field = AppViewModel::class.java.getDeclaredField("_sendUiState") diff --git a/scripts/collect-changelog.sh b/scripts/collect-changelog.sh index 2f8772668e..c6ee914cea 100755 --- a/scripts/collect-changelog.sh +++ b/scripts/collect-changelog.sh @@ -40,7 +40,10 @@ if [[ "$target" != "next" && "$target" != "hotfix" ]]; then exit 1 fi -python3 - "$target" <<'PY' +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +python3 - "$target" "$repo_root" <<'PY' from __future__ import annotations import re @@ -48,7 +51,7 @@ import sys from pathlib import Path TARGET = sys.argv[1] -ROOT = Path.cwd() +ROOT = Path(sys.argv[2]) CHANGELOG = ROOT / "CHANGELOG.md" FRAGMENT_DIR = ROOT / "changelog.d" / TARGET diff --git a/scripts/preview-changelog.sh b/scripts/preview-changelog.sh index 0e47ca2f9f..24630db725 100755 --- a/scripts/preview-changelog.sh +++ b/scripts/preview-changelog.sh @@ -40,7 +40,10 @@ if [[ "$target" != "next" && "$target" != "hotfix" && "$target" != "all" ]]; the exit 1 fi -python3 - "$target" <<'PY' +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +python3 - "$target" "$repo_root" <<'PY' from __future__ import annotations import re @@ -48,7 +51,7 @@ import sys from pathlib import Path TARGET = sys.argv[1] -ROOT = Path.cwd() +ROOT = Path(sys.argv[2]) CHANGELOG_DIR = ROOT / "changelog.d" CATEGORY_LABELS = { From 21825efbea6d93b5f8ed0445596314e5ec383355 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 16:00:32 -0500 Subject: [PATCH 23/35] fix: clean up activity contact titles --- .../components/ActivityContactTitle.kt | 27 +++++++++++++++++++ .../components/ActivityListGrouped.kt | 11 +++++++- .../activity/components/ActivityListSimple.kt | 17 +++++++++++- .../activity/components/ActivityRow.kt | 24 +---------------- 4 files changed, 54 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityContactTitle.kt diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityContactTitle.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityContactTitle.kt new file mode 100644 index 0000000000..283c2df8e8 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityContactTitle.kt @@ -0,0 +1,27 @@ +package to.bitkit.ui.screens.wallets.activity.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.synonym.bitkitcore.Activity +import kotlinx.collections.immutable.ImmutableList +import to.bitkit.R +import to.bitkit.ext.contact +import to.bitkit.ext.isSent +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat + +@Composable +fun contactActivityTitle(activity: Activity, contacts: ImmutableList): String? { + val contactName = remember(activity, contacts) { + val contact = activity.contact() ?: return@remember null + contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, contact) }?.name + } ?: return null + + val titleRes = if (activity.isSent()) { + R.string.contacts__activity_sent_to + } else { + R.string.contacts__activity_received_from + } + return stringResource(titleRes, contactName) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt index be53e358e2..10ee33dfdd 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -10,16 +10,21 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.Activity import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.ext.rawId +import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.TertiaryButton @@ -45,6 +50,10 @@ fun ActivityListGrouped( contentPadding: PaddingValues = PaddingValues(top = 20.dp), titleProvider: @Composable (Activity) -> String? = { null }, ) { + val contacts by activityListViewModel?.contacts?.collectAsStateWithLifecycle() ?: remember { + mutableStateOf(persistentListOf()) + } + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.fillMaxSize() @@ -100,7 +109,7 @@ fun ActivityListGrouped( item = item, onClick = onActivityItemClick, testTag = "Activity-$index", - title = titleProvider(item), + title = titleProvider(item) ?: contactActivityTitle(item, contacts), ) VerticalSpacer(16.dp) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index 7ef5f4b28c..8d17b90ed5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -5,16 +5,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.Activity import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R +import to.bitkit.ui.activityListViewModel import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems @@ -27,12 +32,22 @@ fun ActivityListSimple( onActivityItemClick: (String) -> Unit, ) { if (items.isNullOrEmpty()) return + + val contacts by activityListViewModel?.contacts?.collectAsStateWithLifecycle() ?: remember { + mutableStateOf(persistentListOf()) + } + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth() ) { items.forEachIndexed { index, item -> - ActivityRow(item, onActivityItemClick, testTag = "ActivityShort-$index") + ActivityRow( + item = item, + onClick = onActivityItemClick, + testTag = "ActivityShort-$index", + title = contactActivityTitle(item, contacts), + ) if (index < items.lastIndex) { VerticalSpacer(16.dp) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index e1a2740700..c57009e217 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -27,10 +27,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType -import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.ext.DatePattern -import to.bitkit.ext.contact import to.bitkit.ext.formatted import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer @@ -40,8 +38,6 @@ import to.bitkit.ext.totalValue import to.bitkit.ext.txType import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay -import to.bitkit.models.PubkyProfile -import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies @@ -91,22 +87,9 @@ fun ActivityRow( is Activity.Onchain -> item.v1.confirmed } val isTransfer = item.isTransfer() - val activityListViewModel = activityListViewModel - val contacts by activityListViewModel?.contacts?.collectAsStateWithLifecycle() ?: remember { - mutableStateOf(persistentListOf()) - } - val contactName = remember(item, contacts) { contactName(item, contacts) } - val contactTitle = contactName?.let { - val titleRes = if (item.isSent()) { - R.string.contacts__activity_sent_to - } else { - R.string.contacts__activity_received_from - } - stringResource(titleRes, it) - } var isCpfpChild by remember { mutableStateOf(false) } - val resolvedTitle = (title ?: contactTitle).takeIf { + val resolvedTitle = title.takeIf { shouldUseContactActivityTitle(item, status, isTransfer, isCpfpChild) } @@ -190,11 +173,6 @@ fun ActivityRow( } } -private fun contactName(activity: Activity, contacts: List): String? { - val contact = activity.contact() ?: return null - return contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, contact) }?.name -} - private fun shouldUseContactActivityTitle( activity: Activity, status: PaymentState?, From 57d0de6fee326d97abbb5f1632119a19abe23500 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 16:11:42 -0500 Subject: [PATCH 24/35] fix: encode paykit bip21 invoice --- .../java/to/bitkit/repositories/PublicPaykitRepo.kt | 3 ++- .../to/bitkit/repositories/PublicPaykitRepoTest.kt | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index ca1c4050b6..bb1e99cb22 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -21,6 +21,7 @@ import to.bitkit.models.toLdkNetwork import to.bitkit.services.CoreService import to.bitkit.utils.AppError import to.bitkit.utils.NetworkValidationHelper +import to.bitkit.utils.encodeToUrl import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -102,7 +103,7 @@ class PublicPaykitRepo @Inject constructor( val onchain = sortedEndpoints.firstOrNull { it.methodId.isOnchain } if (lightning != null && onchain != null) { - return "bitcoin:${onchain.value}?lightning=${lightning.value}" + return "bitcoin:${onchain.value}?lightning=${lightning.value.encodeToUrl()}" } return sortedEndpoints.firstOrNull()?.paymentRequest.orEmpty() diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index a70a2e359b..4a9d02fee9 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -315,6 +315,18 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertEquals("bitcoin:bc1ptest?lightning=lnbc1test", request) } + @Test + fun `paymentRequest encodes bolt11 query parameter`() { + val request = PublicPaykitRepo.paymentRequest( + listOf( + endpoint(MethodId.Bolt11, "lnbc1test&label"), + endpoint(MethodId.P2tr, "bc1ptest"), + ), + ) + + assertEquals("bitcoin:bc1ptest?lightning=lnbc1test%26label", request) + } + @Test fun `paymentRequest prefers taproot among multiple onchain endpoints`() { val request = PublicPaykitRepo.paymentRequest( From e9a12c1d160ffe833b4c8e351c7c4fac9099f1ea Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 16:24:48 -0500 Subject: [PATCH 25/35] fix: align contact detail actions --- .../screens/contacts/ContactDetailScreen.kt | 40 +++---------------- .../contacts/ContactDetailViewModel.kt | 28 ------------- app/src/main/res/drawable/ic_activity.xml | 13 ++++++ 3 files changed, 19 insertions(+), 62 deletions(-) create mode 100644 app/src/main/res/drawable/ic_activity.xml diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt index c2e3538bce..1ce9499066 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailScreen.kt @@ -39,7 +39,6 @@ import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn @@ -61,7 +60,6 @@ fun ContactDetailScreen( LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { - ContactDetailEffect.DeleteSuccess -> onBackClick() is ContactDetailEffect.OpenPayment -> onPayContact(it.paymentRequest, it.publicKey) } } @@ -75,14 +73,11 @@ fun ContactDetailScreen( onClickPay = { viewModel.payContact() }, onClickActivity = { uiState.profile?.publicKey?.let { onActivityClick(it) } }, onClickShare = { uiState.profile?.publicKey?.let { shareText(context, it) } }, - onClickDelete = { viewModel.showDeleteConfirmation() }, onClickRetry = { viewModel.loadContact() }, onAddTag = { viewModel.showAddTagSheet() }, onRemoveTag = { viewModel.removeTag(it) }, onDismissAddTagSheet = { viewModel.dismissAddTagSheet() }, onSaveTag = { viewModel.addTag(it) }, - onDismissDeleteDialog = { viewModel.dismissDeleteDialog() }, - onConfirmDelete = { viewModel.deleteContact() }, ) } @@ -95,14 +90,11 @@ private fun Content( onClickPay: () -> Unit, onClickActivity: () -> Unit, onClickShare: () -> Unit, - onClickDelete: () -> Unit, onClickRetry: () -> Unit, onAddTag: () -> Unit, onRemoveTag: (Int) -> Unit, onDismissAddTagSheet: () -> Unit, onSaveTag: (String) -> Unit, - onDismissDeleteDialog: () -> Unit, - onConfirmDelete: () -> Unit, ) { val currentProfile = uiState.profile @@ -124,7 +116,6 @@ private fun Content( onClickPay = onClickPay, onClickActivity = onClickActivity, onClickShare = onClickShare, - onClickDelete = onClickDelete, onAddTag = onAddTag, onRemoveTag = onRemoveTag, ) @@ -132,16 +123,6 @@ private fun Content( } } - if (uiState.showDeleteDialog && currentProfile != null) { - AppAlertDialog( - title = stringResource(R.string.contacts__delete_confirm_title, currentProfile.name), - text = stringResource(R.string.contacts__delete_confirm_text, currentProfile.name), - confirmText = stringResource(R.string.common__delete_yes), - onConfirm = onConfirmDelete, - onDismiss = onDismissDeleteDialog, - ) - } - if (uiState.showAddTagSheet) { AddTagSheet( onDismiss = onDismissAddTagSheet, @@ -161,7 +142,6 @@ private fun ContactBody( onClickPay: () -> Unit, onClickActivity: () -> Unit, onClickShare: () -> Unit, - onClickDelete: () -> Unit, onAddTag: () -> Unit, onRemoveTag: (Int) -> Unit, ) { @@ -190,11 +170,6 @@ private fun ContactBody( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { - ActionButton( - onClick = onClickCopy, - iconRes = R.drawable.ic_copy, - modifier = Modifier.testTag("ContactCopy") - ) if (hasPublicPaymentEndpoint) { ActionButton( onClick = onClickPay, @@ -204,9 +179,14 @@ private fun ContactBody( } ActionButton( onClick = onClickActivity, - iconRes = R.drawable.ic_list_dashes, + iconRes = R.drawable.ic_activity, modifier = Modifier.testTag("ContactActivity") ) + ActionButton( + onClick = onClickCopy, + iconRes = R.drawable.ic_copy, + modifier = Modifier.testTag("ContactCopy") + ) ActionButton( onClick = onClickShare, iconRes = R.drawable.ic_share, @@ -217,11 +197,6 @@ private fun ContactBody( iconRes = R.drawable.ic_edit, modifier = Modifier.testTag("ContactEdit") ) - ActionButton( - onClick = onClickDelete, - iconRes = R.drawable.ic_trash, - modifier = Modifier.testTag("ContactDelete") - ) } VerticalSpacer(32.dp) @@ -320,14 +295,11 @@ private fun Preview() { onClickPay = {}, onClickActivity = {}, onClickShare = {}, - onClickDelete = {}, onClickRetry = {}, onAddTag = {}, onRemoveTag = {}, onDismissAddTagSheet = {}, onSaveTag = {}, - onDismissDeleteDialog = {}, - onConfirmDelete = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index ff0c805377..247cb763a6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -178,32 +178,6 @@ class ContactDetailViewModel @Inject constructor( persistTags(newTags) } - fun showDeleteConfirmation() { - _uiState.update { it.copy(showDeleteDialog = true) } - } - - fun dismissDeleteDialog() { - _uiState.update { it.copy(showDeleteDialog = false) } - } - - fun deleteContact() { - viewModelScope.launch { - _uiState.update { it.copy(showDeleteDialog = false) } - pubkyRepo.removeContact(publicKey) - .onSuccess { - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.contacts__delete_success), - testTag = "ContactDeletedToast", - ) - _effects.emit(ContactDetailEffect.DeleteSuccess) - } - .onFailure { - Logger.error("Failed to delete contact '$redactedPublicKey'", it, context = TAG) - } - } - } - private fun persistTags(tags: List) { val profile = _uiState.value.profile ?: return viewModelScope.launch { @@ -228,10 +202,8 @@ data class ContactDetailUiState( val isLoading: Boolean = false, val hasPublicPaymentEndpoint: Boolean = false, val showAddTagSheet: Boolean = false, - val showDeleteDialog: Boolean = false, ) sealed interface ContactDetailEffect { - data object DeleteSuccess : ContactDetailEffect data class OpenPayment(val paymentRequest: String, val publicKey: String) : ContactDetailEffect } diff --git a/app/src/main/res/drawable/ic_activity.xml b/app/src/main/res/drawable/ic_activity.xml new file mode 100644 index 0000000000..039da09cc7 --- /dev/null +++ b/app/src/main/res/drawable/ic_activity.xml @@ -0,0 +1,13 @@ + + + From 2920a97e257de751c3c39e12a2d6d7cbb817fc9a Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 16:29:50 -0500 Subject: [PATCH 26/35] fix: reset paykit state on signout --- .../java/to/bitkit/repositories/PubkyRepo.kt | 18 +++++++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 0bd2e2167c..e48d6e64da 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.data.PubkyStore +import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.env.Env @@ -52,7 +53,7 @@ sealed class PubkyContactError(message: String) : AppError(message) { data object InvalidFormat : PubkyContactError("Invalid pubky key format") } -@Suppress("TooManyFunctions", "LargeClass") +@Suppress("TooManyFunctions", "LargeClass", "LongParameterList") @Singleton class PubkyRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @@ -60,6 +61,7 @@ class PubkyRepo @Inject constructor( private val keychain: Keychain, private val imageLoader: ImageLoader, private val pubkyStore: PubkyStore, + private val settingsStore: SettingsStore, private val httpClient: HttpClient, ) { companion object { @@ -961,10 +963,24 @@ class PubkyRepo @Inject constructor( private suspend fun clearLocalState() = withContext(ioDispatcher) { runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + runCatching { clearPublicPaykitSharingState() } + .onFailure { Logger.warn("Failed to clear public Paykit sharing state", it, context = TAG) } notifyBackupStateChanged() clearAuthenticatedState() } + private suspend fun clearPublicPaykitSharingState() { + settingsStore.update { + it.copy( + hasConfirmedPublicPaykitEndpoints = false, + sharesPublicPaykitEndpoints = false, + publicPaykitBolt11 = "", + publicPaykitBolt11PaymentHash = "", + publicPaykitBolt11ExpiresAtMillis = 0, + ) + } + } + private fun requireAddableContactPublicKey(publicKey: String, allowExisting: Boolean = false): String { val prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) contactValidationError(prefixedKey, allowExisting)?.let { throw it } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 2ae9e490f9..c377f15a21 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -5,6 +5,7 @@ import coil3.ImageLoader import coil3.disk.DiskCache import coil3.memory.MemoryCache import com.synonym.paykit.FfiPaymentEntry +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Before @@ -20,6 +21,8 @@ import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.models.PubkyProfile @@ -51,10 +54,19 @@ class PubkyRepoTest : BaseUnitTest() { private val keychain = mock() private val imageLoader = mock() private val pubkyStore = mock() + private val settingsStore = mock() + private val settingsFlow = MutableStateFlow(SettingsData()) @Before fun setUp() = runBlocking { + settingsFlow.value = SettingsData() whenever(pubkyStore.data).thenReturn(flowOf(PubkyStoreData())) + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsFlow.value = transform(settingsFlow.value) + Unit + } sut = createSut() } @@ -64,6 +76,7 @@ class PubkyRepoTest : BaseUnitTest() { keychain = keychain, imageLoader = imageLoader, pubkyStore = pubkyStore, + settingsStore = settingsStore, httpClient = mock(), ) @@ -247,6 +260,27 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyStore) { reset() } } + @Test + fun `signOut should clear public Paykit sharing settings`() = test { + authenticateForTesting() + settingsFlow.value = SettingsData( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + publicPaykitBolt11 = "lnbc1old", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = 123L, + ) + + val result = sut.signOut() + + assertTrue(result.isSuccess) + assertFalse(settingsFlow.value.hasConfirmedPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertEquals("", settingsFlow.value.publicPaykitBolt11) + assertEquals("", settingsFlow.value.publicPaykitBolt11PaymentHash) + assertEquals(0, settingsFlow.value.publicPaykitBolt11ExpiresAtMillis) + } + @Test fun `removeBitkitPaymentEndpoints removes only bitkit managed endpoints`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) From 25b823bf4b23aa1c774aa7c79ca3e0727fe2474e Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 16:49:17 -0500 Subject: [PATCH 27/35] fix: refresh paykit on channel events --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 + .../viewmodels/AppViewModelSendFlowTest.kt | 51 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f06f8f493b..50339017c7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -460,6 +460,7 @@ class AppViewModel @Inject constructor( private suspend fun handleChannelReady(event: Event.ChannelReady) { transferRepo.syncTransferStates() walletRepo.syncBalances() + refreshPublicPaykitEndpointsIfEnabled() notifyChannelReady(event) } @@ -476,6 +477,7 @@ class AppViewModel @Inject constructor( } transferRepo.syncTransferStates() walletRepo.syncBalances() + refreshPublicPaykitEndpointsIfEnabled() } private suspend fun createTransferForCounterpartyClose(channelId: String, isForceClose: Boolean) { diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6673e080f3..5bdc538d71 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -12,6 +12,7 @@ import org.junit.Test import org.lightningdevkit.ldknode.Event import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -83,6 +84,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val formatMoneyValue = mock() private val balanceState = MutableStateFlow(BalanceState()) + private val settingsData = MutableStateFlow(SettingsData()) + private val walletState = MutableStateFlow(WalletState()) private val nodeEvents = MutableSharedFlow() private val timedSheetManager = mock() @@ -100,9 +103,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) whenever(walletRepo.balanceState).thenReturn(balanceState) - whenever(walletRepo.walletState).thenReturn(MutableStateFlow(WalletState())) + whenever(walletRepo.walletState).thenReturn(walletState) whenever(walletRepo.walletExists()).thenReturn(true) - whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + whenever(settingsStore.data).thenReturn(settingsData) whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) whenever(timedSheetManager.currentSheet).thenReturn(MutableStateFlow(null)) @@ -310,6 +313,44 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(pendingContactPaymentContext(paymentHash)) } + @Test + fun `channel ready refreshes public Paykit endpoints when sharing enabled`() = test { + enablePublicPaykitSharing() + advanceUntilIdle() + clearInvocations(publicPaykitRepo) + + nodeEvents.emit( + Event.ChannelReady( + channelId = "testChannelId", + userChannelId = "testUserChannelId", + counterpartyNodeId = null, + fundingTxo = null, + ), + ) + advanceUntilIdle() + + verify(publicPaykitRepo).syncCurrentPublishedEndpoints() + } + + @Test + fun `channel closed refreshes public Paykit endpoints when sharing enabled`() = test { + enablePublicPaykitSharing() + advanceUntilIdle() + clearInvocations(publicPaykitRepo) + + nodeEvents.emit( + Event.ChannelClosed( + channelId = "testChannelId", + userChannelId = "testUserChannelId", + counterpartyNodeId = null, + reason = null, + ), + ) + advanceUntilIdle() + + verify(publicPaykitRepo).syncCurrentPublishedEndpoints() + } + @Test fun `amount change clears confirmedWarnings`() = test { setUnifiedState(amount = 1000u) @@ -363,6 +404,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(0L, sut.sendUiState.value.lastLightningFee) } + private suspend fun enablePublicPaykitSharing() { + settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) + walletState.value = WalletState(onchainAddress = "bc1qtest") + whenever { publicPaykitRepo.syncCurrentPublishedEndpoints() }.thenReturn(Result.success(Unit)) + } + @Suppress("UNCHECKED_CAST") private fun setPendingContactPaymentContext(paymentHash: String, publicKey: String) { val field = AppViewModel::class.java.getDeclaredField("pendingContactPaymentContexts") From 70bde1a4e82bf1bd6444e74c32c49296c0e8c142 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 16:58:09 -0500 Subject: [PATCH 28/35] fix: skip quickpay for contacts --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 56 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 50339017c7..cfba902794 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1471,6 +1471,10 @@ class AppViewModel @Inject constructor( } } + private fun hasActiveContactPaymentContext() = synchronized(contactPaymentContextLock) { + activeContactPaymentContext != null + } + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") private suspend fun onScanOnchain(invoice: OnChainInvoice, scanResult: String) { val validatedAddress = runCatching { validateBitcoinAddress(invoice.address) } @@ -1805,6 +1809,8 @@ class AppViewModel @Inject constructor( lnurlPay: LnurlPayData? = null, invoice: LightningInvoice? = null, ): Boolean { + if (hasActiveContactPaymentContext()) return false + val settings = settingsStore.data.first() if (!settings.isQuickPayEnabled || amountSats == 0uL) { return false diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 5bdc538d71..4f0c1a58cc 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1,6 +1,9 @@ package to.bitkit.viewmodels import android.content.Context +import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.Scanner import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow @@ -27,6 +30,7 @@ import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.BlocktankState import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.CurrencyRepo @@ -46,7 +50,9 @@ import to.bitkit.services.AppUpdaterService import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.components.Sheet import to.bitkit.ui.shared.toast.ToastQueueManager +import to.bitkit.ui.sheets.SendRoute import to.bitkit.usecases.FormatMoneyValue import to.bitkit.utils.timedsheets.TimedSheetManager import kotlin.test.assertEquals @@ -108,6 +114,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(settingsData) whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) + whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) + whenever { blocktankRepo.refreshInfo() }.thenReturn(Result.success(Unit)) whenever(timedSheetManager.currentSheet).thenReturn(MutableStateFlow(null)) whenever(migrationService.isShowingMigrationLoading).thenReturn(MutableStateFlow(false)) whenever { migrationService.needsPostMigrationSync() }.thenReturn(false) @@ -313,6 +321,32 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(pendingContactPaymentContext(paymentHash)) } + @Test + fun `lightning scan uses QuickPay when enabled`() = test { + val bolt11 = "lnbcrt1quickpay" + enableQuickPay(thresholdSats = 1000u) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + } + + @Test + fun `contact lightning payment skips QuickPay and opens confirm`() = test { + val bolt11 = "lnbcrt1contact" + enableQuickPay(thresholdSats = 1000u) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.openContactPayment(paymentRequest = bolt11, publicKey = "pubkycontact") + advanceUntilIdle() + + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + @Test fun `channel ready refreshes public Paykit endpoints when sharing enabled`() = test { enablePublicPaykitSharing() @@ -404,6 +438,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(0L, sut.sendUiState.value.lastLightningFee) } + private fun enableQuickPay(thresholdSats: ULong) { + settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) + whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) + } + + private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { + whenever(coreService.decode(bolt11)).thenReturn(Scanner.Lightning(lightningInvoice(bolt11, amountSats))) + whenever(lightningRepo.canSend(amountSats)).thenReturn(true) + } + + private fun lightningInvoice(bolt11: String, amountSats: ULong) = LightningInvoice( + bolt11 = bolt11, + paymentHash = byteArrayOf(1, 2, 3), + amountSatoshis = amountSats, + timestampSeconds = 0u, + expirySeconds = 86_400u, + isExpired = false, + description = "", + networkType = NetworkType.REGTEST, + payeeNodeId = null, + ) + private suspend fun enablePublicPaykitSharing() { settingsData.value = SettingsData(sharesPublicPaykitEndpoints = true) walletState.value = WalletState(onchainAddress = "bc1qtest") From afeb1f47e8fa3cb48e25ae3137d0f7f784fffba6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 21:45:28 -0500 Subject: [PATCH 29/35] fix: update paykit bindings --- app/src/main/java/to/bitkit/services/PubkyService.kt | 11 ++++++++++- gradle/libs.versions.toml | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 9a6a837160..702e09ed43 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -1,5 +1,6 @@ package to.bitkit.services +import android.content.Context import com.synonym.bitkitcore.PubkyProfile import com.synonym.bitkitcore.approvePubkyAuth import com.synonym.bitkitcore.cancelPubkyAuth @@ -19,6 +20,7 @@ import com.synonym.bitkitcore.pubkySignIn import com.synonym.bitkitcore.pubkySignUp import com.synonym.bitkitcore.startPubkyAuth import com.synonym.paykit.FfiPaymentEntry +import com.synonym.paykit.PaykitAndroid import com.synonym.paykit.paykitExportSession import com.synonym.paykit.paykitForceSignOut import com.synonym.paykit.paykitGetCurrentPublicKey @@ -29,19 +31,26 @@ import com.synonym.paykit.paykitIsAuthenticated import com.synonym.paykit.paykitRemovePaymentEndpoint import com.synonym.paykit.paykitSetPaymentEndpoint import com.synonym.paykit.paykitSignOut +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CompletableDeferred import to.bitkit.async.ServiceQueue import to.bitkit.env.Env +import to.bitkit.utils.AppError import javax.inject.Inject import javax.inject.Singleton @Suppress("TooManyFunctions") @Singleton -class PubkyService @Inject constructor() { +class PubkyService @Inject constructor( + @ApplicationContext private val context: Context, +) { private val isSetup = CompletableDeferred() suspend fun initialize() = ServiceQueue.CORE.background { + if (!PaykitAndroid.initialize(context)) { + throw AppError("Failed to initialize Android platform verifier") + } paykitInitialize() isSetup.complete(Unit) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cacf9e64c..6a1a474ce4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.58" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc3" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc5" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 94938d2ccfd54a7bf77bbed6bc76b379767a0817 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 5 May 2026 13:54:08 +0200 Subject: [PATCH 30/35] fix: address paykit review feedback --- .../to/bitkit/repositories/ActivityRepo.kt | 7 +- .../bitkit/repositories/PublicPaykitRepo.kt | 31 ++- ...ontactTitle.kt => ContactActivityTitle.kt} | 0 .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +- .../repositories/PublicPaykitRepoTest.kt | 201 ++++++++---------- .../viewmodels/AppViewModelSendFlowTest.kt | 24 +++ 6 files changed, 134 insertions(+), 135 deletions(-) rename app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/{ActivityContactTitle.kt => ContactActivityTitle.kt} (100%) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 59a769335f..851a7f4f26 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -348,12 +348,7 @@ class ActivityRepo @Inject constructor( getActivities( filter = ActivityFilter.ALL, sortDirection = SortDirection.DESC, - ).getOrThrow().filter { activity -> - when (activity) { - is Activity.Lightning -> PubkyPublicKeyFormat.matches(activity.v1.contact, normalizedKey) - is Activity.Onchain -> PubkyPublicKeyFormat.matches(activity.v1.contact, normalizedKey) - } - } + ).getOrThrow().filter { PubkyPublicKeyFormat.matches(it.contact(), normalizedKey) } }.onFailure { Logger.error("Failed to load contact activities for '$publicKey'", it, context = TAG) } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index bb1e99cb22..89e1d87fd4 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -9,11 +9,12 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher +import to.bitkit.di.json import to.bitkit.env.Env import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat @@ -26,7 +27,6 @@ import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock -import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.ExperimentalTime @@ -57,7 +57,6 @@ class PublicPaykitRepo @Inject constructor( ) { companion object { private val methodIdPattern = Regex("^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$") - private val json = Json { ignoreUnknownKeys = true } private val payablePreferenceOrder = listOf( MethodId.Bolt11, @@ -69,7 +68,7 @@ class PublicPaykitRepo @Inject constructor( ) private val managedMethodIds = MethodId.entries.filter { it.isBitkitManaged } - private val publicBolt11Expiry = 24.hours + private val publicBolt11Expiry = 60.minutes private val publicBolt11RefreshWindow = 30.minutes fun parseEndpoint(methodId: String, endpointData: String): Endpoint? { @@ -94,7 +93,7 @@ class PublicPaykitRepo @Inject constructor( fun serializePayload(value: String): String { val trimmedValue = value.trim() if (trimmedValue.isEmpty()) throw PublicPaykitError.InvalidPayload - return json.encodeToString(PaymentEndpointPayload(value = trimmedValue)) + return buildJsonObject { put("value", trimmedValue) }.toString() } fun paymentRequest(endpoints: List): String { @@ -229,17 +228,9 @@ class PublicPaykitRepo @Inject constructor( } private suspend fun buildWalletEndpoints(refresh: Boolean): List { - if (refresh) { - lightningRepo.executeWhenNodeRunning( - operationName = "sync public Paykit endpoints", - ) { - Result.success(Unit) - }.getOrThrow() - } - val state = walletRepo.walletState.value val endpoints = mutableListOf() - buildPublicBolt11Endpoint()?.let { endpoints += it } + buildPublicBolt11Endpoint(refresh)?.let { endpoints += it } val onchainAddress = state.onchainAddress if (onchainAddress.isNotBlank()) { @@ -256,7 +247,7 @@ class PublicPaykitRepo @Inject constructor( return endpoints } - private suspend fun buildPublicBolt11Endpoint(): Endpoint? { + private suspend fun buildPublicBolt11Endpoint(refresh: Boolean): Endpoint? { if (!lightningRepo.canReceive()) { clearPublicBolt11Metadata() return null @@ -272,6 +263,14 @@ class PublicPaykitRepo @Inject constructor( ) } + if (refresh) { + lightningRepo.executeWhenNodeRunning( + operationName = "sync public Paykit Bolt11 endpoint", + ) { + Result.success(Unit) + }.getOrThrow() + } + val bolt11 = lightningRepo.createInvoice( amountSats = null, description = "", diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityContactTitle.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ContactActivityTitle.kt similarity index 100% rename from app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityContactTitle.kt rename to app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ContactActivityTitle.kt diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index cfba902794..bdbf8a90f0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1373,8 +1373,10 @@ class AppViewModel @Inject constructor( fun preserveContactPaymentContext(paymentHash: String) { synchronized(contactPaymentContextLock) { - activeContactPaymentContext?.let { - pendingContactPaymentContexts[paymentHash] = it + val context = activeContactPaymentContext + if (context != null) { + pendingContactPaymentContexts[paymentHash] = context + activeContactPaymentContext = null } } } diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 4a9d02fee9..c84647de71 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import com.synonym.paykit.FfiPaymentEntry import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.inOrder @@ -29,36 +30,49 @@ import kotlin.time.Instant class PublicPaykitRepoTest : BaseUnitTest() { companion object { private const val NOW_MILLIS = 1_000L + private const val PUBLIC_BOLT11_EXPIRY_SECONDS = 3_600u + } + + private val pubkyRepo = mock() + private val walletRepo = mock() + private val lightningRepo = mock() + private val coreService = mock() + private val settingsStore = mock() + private val clock = mock() + + private val publicKey = MutableStateFlow("pubkyself") + private val walletState = MutableStateFlow(WalletState()) + private val settingsFlow = MutableStateFlow(SettingsData()) + + private lateinit var sut: PublicPaykitRepo + + @Before + fun setUp() { + sut = createRepo() + settingsFlow.value = SettingsData() + publicKey.value = "pubkyself" + walletState.value = WalletState() + + whenever(pubkyRepo.publicKey).thenReturn(publicKey) + whenever(walletRepo.walletState).thenReturn(walletState) + whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MILLIS)) + whenever { pubkyRepo.setPaymentEndpoint(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { pubkyRepo.removePaymentEndpoint(any()) }.thenReturn(Result.success(Unit)) + whenever { settingsStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(SettingsData) -> SettingsData>(0) + settingsFlow.value = transform(settingsFlow.value) + Unit + } } @Test fun `syncCurrentPublishedEndpoints sets desired endpoints and removes obsolete bitkit endpoints`() = test { - val pubkyRepo = mock() - val walletRepo = mock() - val lightningRepo = mock() - val coreService = mock() - val (settingsStore, settingsFlow) = createSettingsStore() - val sut = createRepo( - pubkyRepo = pubkyRepo, - walletRepo = walletRepo, - lightningRepo = lightningRepo, - coreService = coreService, - settingsStore = settingsStore, - ) - - whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) - whenever(walletRepo.walletState).thenReturn( - MutableStateFlow( - WalletState( - onchainAddress = "bc1ptest", - bolt11 = "lnbc1user", - ), - ), + walletState.value = WalletState( + onchainAddress = "bc1ptest", + bolt11 = "lnbc1user", ) - whenever(lightningRepo.canReceive()).thenReturn(true) - stubPublicInvoice(lightningRepo, coreService, "lnbc1public", byteArrayOf(1, 2, 3)) - whenever(pubkyRepo.setPaymentEndpoint(any(), any())).thenReturn(Result.success(Unit)) - whenever(pubkyRepo.removePaymentEndpoint(any())).thenReturn(Result.success(Unit)) + stubPublicInvoice("lnbc1public", byteArrayOf(1, 2, 3)) whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( Result.success( listOf( @@ -85,20 +99,34 @@ class PublicPaykitRepoTest : BaseUnitTest() { verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.P2tr.rawValue) } + @Test + fun `syncPublishedEndpoints publishes onchain endpoint when lightning cannot receive`() = test { + walletState.value = WalletState(onchainAddress = "bc1ptest") + whenever(lightningRepo.canReceive()).thenReturn(false) + whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn(Result.success(emptyList())) + + val result = sut.syncPublishedEndpoints(publish = true) + + assertTrue(result.isSuccess) + assertEquals("", settingsFlow.value.publicPaykitBolt11) + verify(pubkyRepo).setPaymentEndpoint(MethodId.P2tr.rawValue, """{"value":"bc1ptest"}""") + verify(lightningRepo, never()).createInvoice( + amountSats = null, + description = "", + expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS, + ) + } + @Test fun `syncPublishedEndpoints removes bitkit managed endpoints and preserves lnurl`() = test { - val pubkyRepo = mock() - val (settingsStore, settingsFlow) = createSettingsStore( + setSettings( SettingsData( publicPaykitBolt11 = "lnbc1old", publicPaykitBolt11PaymentHash = "010203", publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), ), ) - val sut = createRepo(pubkyRepo = pubkyRepo, settingsStore = settingsStore) - whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) - whenever(pubkyRepo.removePaymentEndpoint(any())).thenReturn(Result.success(Unit)) whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( Result.success( listOf( @@ -120,48 +148,32 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `syncCurrentPublishedEndpoints reuses fresh public bolt11`() = test { - val pubkyRepo = mock() - val walletRepo = mock() - val lightningRepo = mock() - val coreService = mock() - val clock = createClock() - val (settingsStore) = createSettingsStore( + setSettings( SettingsData( publicPaykitBolt11 = "lnbc1cached", publicPaykitBolt11PaymentHash = "010203", publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), ), ) - val sut = createRepo( - pubkyRepo = pubkyRepo, - walletRepo = walletRepo, - lightningRepo = lightningRepo, - coreService = coreService, - settingsStore = settingsStore, - clock = clock, - ) - whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) - whenever(walletRepo.walletState).thenReturn(MutableStateFlow(WalletState())) whenever(lightningRepo.canReceive()).thenReturn(true) - whenever(pubkyRepo.setPaymentEndpoint(any(), any())).thenReturn(Result.success(Unit)) whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn(Result.success(emptyList())) val result = sut.syncCurrentPublishedEndpoints() assertTrue(result.isSuccess) - verify(lightningRepo, never()).createInvoice(amountSats = null, description = "", expirySeconds = 86_400u) + verify(lightningRepo, never()).createInvoice( + amountSats = null, + description = "", + expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS, + ) verify(coreService, never()).decode(any()) verify(pubkyRepo).setPaymentEndpoint(MethodId.Bolt11.rawValue, """{"value":"lnbc1cached"}""") } @Test fun `refreshPublishedBolt11ForPayment rotates paid public bolt11`() = test { - val pubkyRepo = mock() - val walletRepo = mock() - val lightningRepo = mock() - val coreService = mock() - val (settingsStore, settingsFlow) = createSettingsStore( + setSettings( SettingsData( sharesPublicPaykitEndpoints = true, publicPaykitBolt11 = "lnbc1old", @@ -169,19 +181,8 @@ class PublicPaykitRepoTest : BaseUnitTest() { publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), ), ) - val sut = createRepo( - pubkyRepo = pubkyRepo, - walletRepo = walletRepo, - lightningRepo = lightningRepo, - coreService = coreService, - settingsStore = settingsStore, - ) - whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyself")) - whenever(walletRepo.walletState).thenReturn(MutableStateFlow(WalletState())) - whenever(lightningRepo.canReceive()).thenReturn(true) - stubPublicInvoice(lightningRepo, coreService, "lnbc1new", byteArrayOf(4, 5, 6)) - whenever(pubkyRepo.setPaymentEndpoint(any(), any())).thenReturn(Result.success(Unit)) + stubPublicInvoice("lnbc1new", byteArrayOf(4, 5, 6)) whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( Result.success(listOf(paymentEntry(MethodId.Bolt11, "lnbc1old"))), ) @@ -196,10 +197,7 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `refreshPublishedBolt11ForPayment ignores unrelated payment hash`() = test { - val pubkyRepo = mock() - val lightningRepo = mock() - val coreService = mock() - val (settingsStore) = createSettingsStore( + setSettings( SettingsData( sharesPublicPaykitEndpoints = true, publicPaykitBolt11 = "lnbc1old", @@ -207,29 +205,23 @@ class PublicPaykitRepoTest : BaseUnitTest() { publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), ), ) - val sut = createRepo( - pubkyRepo = pubkyRepo, - lightningRepo = lightningRepo, - coreService = coreService, - settingsStore = settingsStore, - ) val result = sut.refreshPublishedBolt11ForPayment("unrelated") assertTrue(result.isSuccess) - verify(lightningRepo, never()).createInvoice(amountSats = null, description = "", expirySeconds = 86_400u) + verify(lightningRepo, never()).createInvoice( + amountSats = null, + description = "", + expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS, + ) verify(pubkyRepo, never()).setPaymentEndpoint(any(), any()) } @Test fun `syncCurrentPublishedEndpoints returns SessionNotActive when no pubky session exists`() = test { - val pubkyRepo = mock() - val walletRepo = mock() - val sut = createRepo(pubkyRepo = pubkyRepo, walletRepo = walletRepo) - - whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null)) + publicKey.value = null whenever(pubkyRepo.currentPublicKey()).thenReturn(Result.success(null)) - whenever(walletRepo.walletState).thenReturn(MutableStateFlow(WalletState(onchainAddress = "bc1ptest"))) + walletState.value = WalletState(onchainAddress = "bc1ptest") val error = sut.syncCurrentPublishedEndpoints().exceptionOrNull() @@ -397,12 +389,12 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Suppress("LongParameterList") private fun createRepo( - pubkyRepo: PubkyRepo = mock(), - walletRepo: WalletRepo = mock(), - lightningRepo: LightningRepo = mock(), - coreService: CoreService = mock(), - settingsStore: SettingsStore = createSettingsStore().first, - clock: Clock = createClock(), + pubkyRepo: PubkyRepo = this.pubkyRepo, + walletRepo: WalletRepo = this.walletRepo, + lightningRepo: LightningRepo = this.lightningRepo, + coreService: CoreService = this.coreService, + settingsStore: SettingsStore = this.settingsStore, + clock: Clock = this.clock, ) = PublicPaykitRepo( ioDispatcher = testDispatcher, pubkyRepo = pubkyRepo, @@ -418,36 +410,23 @@ class PublicPaykitRepoTest : BaseUnitTest() { endpointData = """{"value":"$value"}""", ) - private fun createSettingsStore( - initial: SettingsData = SettingsData(), - ): Pair> { - val settingsStore = mock() - val flow = MutableStateFlow(initial) - whenever(settingsStore.data).thenReturn(flow) - whenever { settingsStore.update(any()) }.thenAnswer { - val transform = it.getArgument<(SettingsData) -> SettingsData>(0) - flow.value = transform(flow.value) - Unit - } - return settingsStore to flow - } - - private fun createClock(): Clock { - val clock = mock() - whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MILLIS)) - return clock + private fun setSettings(settings: SettingsData) { + settingsFlow.value = settings } private fun freshExpiryMillis() = NOW_MILLIS + 1.hours.inWholeMilliseconds private suspend fun stubPublicInvoice( - lightningRepo: LightningRepo, - coreService: CoreService, bolt11: String, paymentHash: ByteArray, ) { + whenever(lightningRepo.canReceive()).thenReturn(true) whenever( - lightningRepo.createInvoice(amountSats = null, description = "", expirySeconds = 86_400u) + lightningRepo.createInvoice( + amountSats = null, + description = "", + expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS, + ) ).thenReturn(Result.success(bolt11)) whenever(coreService.decode(bolt11)).thenReturn(Scanner.Lightning(lightningInvoice(bolt11, paymentHash))) } @@ -455,9 +434,9 @@ class PublicPaykitRepoTest : BaseUnitTest() { private fun lightningInvoice(bolt11: String, paymentHash: ByteArray) = LightningInvoice( bolt11 = bolt11, paymentHash = paymentHash, - amountSatoshis = 0u, + amountSatoshis = 0uL, timestampSeconds = 0u, - expirySeconds = 86_400u, + expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS.toULong(), isExpired = false, description = "", networkType = NetworkType.REGTEST, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 4f0c1a58cc..900101fe90 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -321,6 +321,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(pendingContactPaymentContext(paymentHash)) } + @Test + fun `preserveContactPaymentContext moves active context to pending`() = test { + val paymentHash = "pending_hash" + val contactKey = "pubkycontact" + setActiveContactPaymentContext(contactKey) + + sut.preserveContactPaymentContext(paymentHash) + + assertNull(activeContactPaymentContext()) + assertEquals(contactKey, pendingContactPaymentContext(paymentHash)?.publicKey) + } + @Test fun `lightning scan uses QuickPay when enabled`() = test { val bolt11 = "lnbcrt1quickpay" @@ -474,6 +486,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { contexts[paymentHash] = ContactPaymentContext(publicKey) } + private fun setActiveContactPaymentContext(publicKey: String) { + val field = AppViewModel::class.java.getDeclaredField("activeContactPaymentContext") + field.isAccessible = true + field.set(sut, ContactPaymentContext(publicKey)) + } + + private fun activeContactPaymentContext(): ContactPaymentContext? { + val field = AppViewModel::class.java.getDeclaredField("activeContactPaymentContext") + field.isAccessible = true + return field.get(sut) as ContactPaymentContext? + } + @Suppress("UNCHECKED_CAST") private fun pendingContactPaymentContext(paymentHash: String): ContactPaymentContext? { val field = AppViewModel::class.java.getDeclaredField("pendingContactPaymentContexts") From d666cebceb0efd443c37e835a7dd992f6f1aac92 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 5 May 2026 14:08:18 +0200 Subject: [PATCH 31/35] fix: restore paykit node gate --- .../bitkit/repositories/PublicPaykitRepo.kt | 20 +++++++++---------- .../repositories/PublicPaykitRepoTest.kt | 18 ----------------- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 89e1d87fd4..4bbf0ed9ab 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -228,9 +228,17 @@ class PublicPaykitRepo @Inject constructor( } private suspend fun buildWalletEndpoints(refresh: Boolean): List { + if (refresh) { + lightningRepo.executeWhenNodeRunning( + operationName = "sync public Paykit endpoints", + ) { + Result.success(Unit) + }.getOrThrow() + } + val state = walletRepo.walletState.value val endpoints = mutableListOf() - buildPublicBolt11Endpoint(refresh)?.let { endpoints += it } + buildPublicBolt11Endpoint()?.let { endpoints += it } val onchainAddress = state.onchainAddress if (onchainAddress.isNotBlank()) { @@ -247,7 +255,7 @@ class PublicPaykitRepo @Inject constructor( return endpoints } - private suspend fun buildPublicBolt11Endpoint(refresh: Boolean): Endpoint? { + private suspend fun buildPublicBolt11Endpoint(): Endpoint? { if (!lightningRepo.canReceive()) { clearPublicBolt11Metadata() return null @@ -263,14 +271,6 @@ class PublicPaykitRepo @Inject constructor( ) } - if (refresh) { - lightningRepo.executeWhenNodeRunning( - operationName = "sync public Paykit Bolt11 endpoint", - ) { - Result.success(Unit) - }.getOrThrow() - } - val bolt11 = lightningRepo.createInvoice( amountSats = null, description = "", diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index c84647de71..a35e54ba1a 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -99,24 +99,6 @@ class PublicPaykitRepoTest : BaseUnitTest() { verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.P2tr.rawValue) } - @Test - fun `syncPublishedEndpoints publishes onchain endpoint when lightning cannot receive`() = test { - walletState.value = WalletState(onchainAddress = "bc1ptest") - whenever(lightningRepo.canReceive()).thenReturn(false) - whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn(Result.success(emptyList())) - - val result = sut.syncPublishedEndpoints(publish = true) - - assertTrue(result.isSuccess) - assertEquals("", settingsFlow.value.publicPaykitBolt11) - verify(pubkyRepo).setPaymentEndpoint(MethodId.P2tr.rawValue, """{"value":"bc1ptest"}""") - verify(lightningRepo, never()).createInvoice( - amountSats = null, - description = "", - expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS, - ) - } - @Test fun `syncPublishedEndpoints removes bitkit managed endpoints and preserves lnurl`() = test { setSettings( From b54ba49b6a67263f46d4c6b1aa02e083bed9b992 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 5 May 2026 14:21:41 +0200 Subject: [PATCH 32/35] fix: type paykit endpoint payload --- .../to/bitkit/repositories/PublicPaykitRepo.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 4bbf0ed9ab..bbe797913a 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -9,12 +9,11 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher -import to.bitkit.di.json import to.bitkit.env.Env import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat @@ -29,6 +28,7 @@ import javax.inject.Singleton import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes import kotlin.time.ExperimentalTime +import to.bitkit.di.json as appJson sealed class PublicPaykitError(message: String) : AppError(message) { data object InvalidPayload : PublicPaykitError("Invalid Paykit payment endpoint payload") @@ -76,7 +76,7 @@ class PublicPaykitRepo @Inject constructor( val knownMethodId = MethodId.fromRawValue(methodId) ?: return null val payload = runCatching { - json.decodeFromString(endpointData) + appJson.decodeFromString(endpointData) }.getOrNull() ?: return null val value = payload.value.trim() if (value.isEmpty()) return null @@ -93,7 +93,7 @@ class PublicPaykitRepo @Inject constructor( fun serializePayload(value: String): String { val trimmedValue = value.trim() if (trimmedValue.isEmpty()) throw PublicPaykitError.InvalidPayload - return buildJsonObject { put("value", trimmedValue) }.toString() + return Json.encodeToString(PublishedPaymentEndpointPayload(value = trimmedValue)) } fun paymentRequest(endpoints: List): String { @@ -368,3 +368,8 @@ private data class PaymentEndpointPayload( val min: String? = null, val max: String? = null, ) + +@Serializable +private data class PublishedPaymentEndpointPayload( + val value: String, +) From 774f154409312519c3d07485c298685bacf9c912 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 5 May 2026 14:26:19 +0200 Subject: [PATCH 33/35] fix: align bolt11 expiry --- app/src/main/java/to/bitkit/env/Env.kt | 3 +++ .../java/to/bitkit/repositories/BlocktankRepo.kt | 3 ++- .../java/to/bitkit/repositories/LightningRepo.kt | 5 +++-- .../java/to/bitkit/repositories/PublicPaykitRepo.kt | 13 +++++-------- .../java/to/bitkit/services/LightningService.kt | 13 +++++++++++-- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 4 ++-- .../to/bitkit/repositories/LightningRepoTest.kt | 9 +++++++-- .../to/bitkit/repositories/PublicPaykitRepoTest.kt | 3 ++- 8 files changed, 35 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index e3b4b76024..3a1c0bf634 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -245,6 +245,9 @@ internal object Env { @Suppress("ConstPropertyName") object Defaults { + /** Default Bolt11 invoice expiry in seconds. */ + const val bolt11InvoiceExpirySeconds = 3_600u + /** Recommended transaction base fee in sats */ const val recommendedBaseFee = 256u diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 43f123ab45..d5c7b44d4a 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -41,6 +41,7 @@ import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore import to.bitkit.di.BgDispatcher +import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.nowTimestamp @@ -462,7 +463,7 @@ class BlocktankRepo @Inject constructor( val invoice = lightningRepo.createInvoice( amountSats = null, description = "blocktank-gift-code:$code", - expirySeconds = 3600u, + expirySeconds = Defaults.bolt11InvoiceExpirySeconds, ).getOrThrow() Logger.debug("Created invoice for gift code, requesting payment from LSP", context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 76bc4f89cd..840bed1426 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -58,6 +58,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowTimestamp @@ -917,7 +918,7 @@ class LightningRepo @Inject constructor( suspend fun createInvoice( amountSats: ULong? = null, description: String, - expirySeconds: UInt = 86_400u, + expirySeconds: UInt = Defaults.bolt11InvoiceExpirySeconds, ): Result = executeWhenNodeRunning("createInvoice") { updateGeoBlockState() runCatching { lightningService.receive(amountSats, description, expirySeconds) } @@ -926,7 +927,7 @@ class LightningRepo @Inject constructor( suspend fun createInvoiceMsats( amountMsats: ULong, description: String, - expirySeconds: UInt = 86_400u, + expirySeconds: UInt = Defaults.bolt11InvoiceExpirySeconds, ): Result = executeWhenNodeRunning("createInvoiceMsats") { updateGeoBlockState() runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index bbe797913a..262273fe15 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -14,6 +14,7 @@ import kotlinx.serialization.json.Json import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher +import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat @@ -27,6 +28,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import to.bitkit.di.json as appJson @@ -68,7 +70,7 @@ class PublicPaykitRepo @Inject constructor( ) private val managedMethodIds = MethodId.entries.filter { it.isBitkitManaged } - private val publicBolt11Expiry = 60.minutes + private val publicBolt11Expiry = Defaults.bolt11InvoiceExpirySeconds.toInt().seconds private val publicBolt11RefreshWindow = 30.minutes fun parseEndpoint(methodId: String, endpointData: String): Endpoint? { @@ -93,7 +95,7 @@ class PublicPaykitRepo @Inject constructor( fun serializePayload(value: String): String { val trimmedValue = value.trim() if (trimmedValue.isEmpty()) throw PublicPaykitError.InvalidPayload - return Json.encodeToString(PublishedPaymentEndpointPayload(value = trimmedValue)) + return Json.encodeToString(PaymentEndpointPayload(value = trimmedValue)) } fun paymentRequest(endpoints: List): String { @@ -274,7 +276,7 @@ class PublicPaykitRepo @Inject constructor( val bolt11 = lightningRepo.createInvoice( amountSats = null, description = "", - expirySeconds = publicBolt11Expiry.inWholeSeconds.toUInt(), + expirySeconds = Defaults.bolt11InvoiceExpirySeconds, ).getOrThrow() val invoice = (coreService.decode(bolt11) as Scanner.Lightning).invoice val expiresAtMillis = clock.now().plus(publicBolt11Expiry).toEpochMilliseconds() @@ -368,8 +370,3 @@ private data class PaymentEndpointPayload( val min: String? = null, val max: String? = null, ) - -@Serializable -private data class PublishedPaymentEndpointPayload( - val value: String, -) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 6fbe4925d8..977a928b83 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -46,6 +46,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.ext.uByteList @@ -592,11 +593,19 @@ class LightningService @Inject constructor( return true } - suspend fun receive(sat: ULong? = null, description: String, expirySecs: UInt = 3600u): String { + suspend fun receive( + sat: ULong? = null, + description: String, + expirySecs: UInt = Defaults.bolt11InvoiceExpirySeconds, + ): String { return receiveMsats(amountMsat = sat?.let { it * 1000u }, description = description, expirySecs = expirySecs) } - suspend fun receiveMsats(amountMsat: ULong? = null, description: String, expirySecs: UInt = 3600u): String { + suspend fun receiveMsats( + amountMsat: ULong? = null, + description: String, + expirySecs: UInt = Defaults.bolt11InvoiceExpirySeconds, + ): String { val node = this.node ?: throw ServiceError.NodeNotSetup() val message = description diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index bdbf8a90f0..97099a267b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2071,7 +2071,7 @@ class AppViewModel @Inject constructor( lightningRepo.createInvoiceMsats( amountMsats = lnurl.data.maxWithdrawable, description = lnurl.data.defaultDescription, - expirySeconds = 3600u, + expirySeconds = Defaults.bolt11InvoiceExpirySeconds, ) } else { val withdrawAmountSats = _sendUiState.value.amount.coerceAtLeast( @@ -2081,7 +2081,7 @@ class AppViewModel @Inject constructor( lightningRepo.createInvoice( amountSats = withdrawAmountSats, description = lnurl.data.defaultDescription, - expirySeconds = 3600u, + expirySeconds = Defaults.bolt11InvoiceExpirySeconds, ) }.getOrNull() diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 4c0fb52f05..f22078a41f 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -40,6 +40,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Defaults import to.bitkit.ext.createChannelDetails import to.bitkit.ext.of import to.bitkit.models.CoinSelectionPreference @@ -200,11 +201,15 @@ class LightningRepoTest : BaseUnitTest() { lightningService.receive( sat = 100uL, description = "test", - expirySecs = 3600u + expirySecs = Defaults.bolt11InvoiceExpirySeconds, ) ).thenReturn(testInvoice) - val result = sut.createInvoice(amountSats = 100uL, description = "test", expirySeconds = 3600u) + val result = sut.createInvoice( + amountSats = 100uL, + description = "test", + expirySeconds = Defaults.bolt11InvoiceExpirySeconds, + ) assertTrue(result.isSuccess) assertEquals(testInvoice, result.getOrNull()) } diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index a35e54ba1a..b4da14ad8e 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -15,6 +15,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.env.Defaults import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -30,7 +31,7 @@ import kotlin.time.Instant class PublicPaykitRepoTest : BaseUnitTest() { companion object { private const val NOW_MILLIS = 1_000L - private const val PUBLIC_BOLT11_EXPIRY_SECONDS = 3_600u + private const val PUBLIC_BOLT11_EXPIRY_SECONDS = Defaults.bolt11InvoiceExpirySeconds } private val pubkyRepo = mock() From 1e4dd8341fab32ae34f155bc543e2e53f6c6bfee Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 5 May 2026 14:51:39 +0200 Subject: [PATCH 34/35] fix: restore paykit invoice expiry --- .../main/java/to/bitkit/repositories/PublicPaykitRepo.kt | 7 +++---- .../java/to/bitkit/repositories/PublicPaykitRepoTest.kt | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 262273fe15..8d8d6bd157 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -14,7 +14,6 @@ import kotlinx.serialization.json.Json import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher -import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat @@ -27,8 +26,8 @@ import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import to.bitkit.di.json as appJson @@ -70,7 +69,7 @@ class PublicPaykitRepo @Inject constructor( ) private val managedMethodIds = MethodId.entries.filter { it.isBitkitManaged } - private val publicBolt11Expiry = Defaults.bolt11InvoiceExpirySeconds.toInt().seconds + private val publicBolt11Expiry = 24.hours private val publicBolt11RefreshWindow = 30.minutes fun parseEndpoint(methodId: String, endpointData: String): Endpoint? { @@ -276,7 +275,7 @@ class PublicPaykitRepo @Inject constructor( val bolt11 = lightningRepo.createInvoice( amountSats = null, description = "", - expirySeconds = Defaults.bolt11InvoiceExpirySeconds, + expirySeconds = publicBolt11Expiry.inWholeSeconds.toUInt(), ).getOrThrow() val invoice = (coreService.decode(bolt11) as Scanner.Lightning).invoice val expiresAtMillis = clock.now().plus(publicBolt11Expiry).toEpochMilliseconds() diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index b4da14ad8e..7b2598ed9f 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -15,7 +15,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore -import to.bitkit.env.Defaults import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -31,7 +30,7 @@ import kotlin.time.Instant class PublicPaykitRepoTest : BaseUnitTest() { companion object { private const val NOW_MILLIS = 1_000L - private const val PUBLIC_BOLT11_EXPIRY_SECONDS = Defaults.bolt11InvoiceExpirySeconds + private const val PUBLIC_BOLT11_EXPIRY_SECONDS = 86_400u } private val pubkyRepo = mock() From 50e15520da86c83957e705c2d3a9b19309e2574d Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 5 May 2026 12:43:19 -0500 Subject: [PATCH 35/35] fix: harden paykit endpoints --- .../to/bitkit/repositories/LightningRepo.kt | 5 +-- .../bitkit/repositories/PublicPaykitRepo.kt | 44 ++++++++++++++----- .../bitkit/repositories/LightningRepoTest.kt | 4 +- .../repositories/PublicPaykitRepoTest.kt | 40 ++++++++++++++++- 4 files changed, 76 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 840bed1426..76bc4f89cd 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -58,7 +58,6 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher -import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowTimestamp @@ -918,7 +917,7 @@ class LightningRepo @Inject constructor( suspend fun createInvoice( amountSats: ULong? = null, description: String, - expirySeconds: UInt = Defaults.bolt11InvoiceExpirySeconds, + expirySeconds: UInt = 86_400u, ): Result = executeWhenNodeRunning("createInvoice") { updateGeoBlockState() runCatching { lightningService.receive(amountSats, description, expirySeconds) } @@ -927,7 +926,7 @@ class LightningRepo @Inject constructor( suspend fun createInvoiceMsats( amountMsats: ULong, description: String, - expirySeconds: UInt = Defaults.bolt11InvoiceExpirySeconds, + expirySeconds: UInt = 86_400u, ): Result = executeWhenNodeRunning("createInvoiceMsats") { updateGeoBlockState() runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 8d8d6bd157..d8de750a6b 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -11,6 +11,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.lightningdevkit.ldknode.Network import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher @@ -59,6 +60,12 @@ class PublicPaykitRepo @Inject constructor( companion object { private val methodIdPattern = Regex("^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$") + private val payloadJson = Json(appJson) { + prettyPrint = false + isLenient = false + encodeDefaults = false + } + private val payablePreferenceOrder = listOf( MethodId.Bolt11, MethodId.Lnurl, @@ -77,7 +84,7 @@ class PublicPaykitRepo @Inject constructor( val knownMethodId = MethodId.fromRawValue(methodId) ?: return null val payload = runCatching { - appJson.decodeFromString(endpointData) + payloadJson.decodeFromString(endpointData) }.getOrNull() ?: return null val value = payload.value.trim() if (value.isEmpty()) return null @@ -94,7 +101,7 @@ class PublicPaykitRepo @Inject constructor( fun serializePayload(value: String): String { val trimmedValue = value.trim() if (trimmedValue.isEmpty()) throw PublicPaykitError.InvalidPayload - return Json.encodeToString(PaymentEndpointPayload(value = trimmedValue)) + return payloadJson.encodeToString(PaymentEndpointPayload(value = trimmedValue)) } fun paymentRequest(endpoints: List): String { @@ -277,7 +284,8 @@ class PublicPaykitRepo @Inject constructor( description = "", expirySeconds = publicBolt11Expiry.inWholeSeconds.toUInt(), ).getOrThrow() - val invoice = (coreService.decode(bolt11) as Scanner.Lightning).invoice + val invoice = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice + ?: throw PublicPaykitError.InvalidPayload val expiresAtMillis = clock.now().plus(publicBolt11Expiry).toEpochMilliseconds() settingsStore.update { @@ -346,18 +354,34 @@ data class Endpoint( } enum class MethodId( - val rawValue: String, + private val fixedRawValue: String? = null, + private val onchainEndpoint: String? = null, val isOnchain: Boolean = false, val isBitkitManaged: Boolean = false, ) { - Bolt11("btc-lightning-bolt11", isBitkitManaged = true), - Lnurl("btc-lightning-lnurl"), - P2tr("btc-bitcoin-p2tr", isOnchain = true, isBitkitManaged = true), - P2wpkh("btc-bitcoin-p2wpkh", isOnchain = true, isBitkitManaged = true), - P2sh("btc-bitcoin-p2sh", isOnchain = true, isBitkitManaged = true), - P2pkh("btc-bitcoin-p2pkh", isOnchain = true, isBitkitManaged = true), + Bolt11(fixedRawValue = "btc-lightning-bolt11", isBitkitManaged = true), + Lnurl(fixedRawValue = "btc-lightning-lnurl"), + P2tr(onchainEndpoint = "p2tr", isOnchain = true, isBitkitManaged = true), + P2wpkh(onchainEndpoint = "p2wpkh", isOnchain = true, isBitkitManaged = true), + P2sh(onchainEndpoint = "p2sh", isOnchain = true, isBitkitManaged = true), + P2pkh(onchainEndpoint = "p2pkh", isOnchain = true, isBitkitManaged = true), ; + val rawValue: String + get() = rawValueForNetwork(Env.network) + + fun rawValueForNetwork(network: Network): String { + fixedRawValue?.let { return it } + val endpoint = checkNotNull(onchainEndpoint) + val rail = when (network) { + Network.BITCOIN -> "bitcoin" + Network.REGTEST -> "regtest" + Network.TESTNET -> "testnet" + Network.SIGNET -> "signet" + } + return "btc-$rail-$endpoint" + } + companion object { fun fromRawValue(value: String): MethodId? = entries.firstOrNull { it.rawValue == value } } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index f22078a41f..877e23dd74 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -40,7 +40,6 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.keychain.Keychain -import to.bitkit.env.Defaults import to.bitkit.ext.createChannelDetails import to.bitkit.ext.of import to.bitkit.models.CoinSelectionPreference @@ -201,14 +200,13 @@ class LightningRepoTest : BaseUnitTest() { lightningService.receive( sat = 100uL, description = "test", - expirySecs = Defaults.bolt11InvoiceExpirySeconds, + expirySecs = 86_400u, ) ).thenReturn(testInvoice) val result = sut.createInvoice( amountSats = 100uL, description = "test", - expirySeconds = Defaults.bolt11InvoiceExpirySeconds, ) assertTrue(result.isSuccess) assertEquals(testInvoice, result.getOrNull()) diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 7b2598ed9f..0e773415d9 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -7,6 +7,7 @@ import com.synonym.paykit.FfiPaymentEntry import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.Network import org.mockito.kotlin.any import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -211,6 +212,25 @@ class PublicPaykitRepoTest : BaseUnitTest() { verify(pubkyRepo, never()).setPaymentEndpoint(any(), any()) } + @Test + fun `syncCurrentPublishedEndpoints returns InvalidPayload when public bolt11 decode is not lightning`() = test { + walletState.value = WalletState(onchainAddress = "bc1ptest") + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever( + lightningRepo.createInvoice( + amountSats = null, + description = "", + expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS, + ) + ).thenReturn(Result.success("not-lightning")) + whenever(coreService.decode("not-lightning")).thenReturn(mock()) + + val error = sut.syncCurrentPublishedEndpoints().exceptionOrNull() + + assertEquals(PublicPaykitError.InvalidPayload, error) + verify(pubkyRepo, never()).setPaymentEndpoint(any(), any()) + } + @Test fun `parseEndpoint accepts Paykit JSON payloads`() { val endpoint = PublicPaykitRepo.parseEndpoint( @@ -237,13 +257,23 @@ class PublicPaykitRepoTest : BaseUnitTest() { @Test fun `parseEndpoint rejects raw string payloads`() { val endpoint = PublicPaykitRepo.parseEndpoint( - methodId = "btc-bitcoin-p2wpkh", + methodId = MethodId.P2wpkh.rawValue, endpointData = "bc1qexampleaddress", ) assertNull(endpoint) } + @Test + fun `parseEndpoint rejects lenient JSON payloads`() { + val endpoint = PublicPaykitRepo.parseEndpoint( + methodId = MethodId.P2wpkh.rawValue, + endpointData = """{value:"bc1qexampleaddress"}""", + ) + + assertNull(endpoint) + } + @Test fun `parseEndpoint rejects unsupported method ids`() { val endpoint = PublicPaykitRepo.parseEndpoint( @@ -355,6 +385,14 @@ class PublicPaykitRepoTest : BaseUnitTest() { } } + @Test + fun `onchain method ids use network rail`() { + assertEquals("btc-bitcoin-p2tr", MethodId.P2tr.rawValueForNetwork(Network.BITCOIN)) + assertEquals("btc-testnet-p2wpkh", MethodId.P2wpkh.rawValueForNetwork(Network.TESTNET)) + assertEquals("btc-regtest-p2sh", MethodId.P2sh.rawValueForNetwork(Network.REGTEST)) + assertEquals("btc-signet-p2pkh", MethodId.P2pkh.rawValueForNetwork(Network.SIGNET)) + } + @Test fun `onchainMethodId selects address method id`() { assertEquals(MethodId.P2tr, PublicPaykitRepo.onchainMethodId("bc1ptest"))