diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 965fd3da9..03888d46b 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -100,6 +100,11 @@ data class SettingsData( val hasSeenShopIntro: Boolean = false, val hasSeenProfileIntro: Boolean = false, 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 1992e08cd..629b84842 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/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index edf0f08e6..3a1c0bf63 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 @@ -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/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 7df94bc93..89708073b 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 @@ -92,6 +97,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 +111,7 @@ fun LightningActivity.Companion.create( message = message, timestamp = timestamp, preimage = preimage, + contact = contact, createdAt = createdAt, updatedAt = updatedAt, seenAt = seenAt, @@ -128,6 +135,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 +156,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/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index fe9a86efc..6572848a8 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/models/MSat.kt b/app/src/main/java/to/bitkit/models/MSat.kt index 20b0936e9..ea91b96c9 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/models/PubkyPublicKeyFormat.kt b/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt index acadf9eb3..357809a53 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/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 51df37d20..851a7f4f2 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -33,12 +33,15 @@ 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.contact 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 +58,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 +342,69 @@ 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 { PubkyPublicKeyFormat.matches(it.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.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 +582,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/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 43f123ab4..d5c7b44d4 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 9402c01d9..76bc4f89c 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/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 6cf2104c2..e48d6e64d 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 @@ -25,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 @@ -46,11 +48,12 @@ 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") } -@Suppress("TooManyFunctions", "LargeClass") +@Suppress("TooManyFunctions", "LargeClass", "LongParameterList") @Singleton class PubkyRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @@ -58,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 { @@ -204,9 +208,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 @@ -221,8 +223,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 } } @@ -264,6 +264,7 @@ class PubkyRepo @Inject constructor( _authState.update { PubkyAuthState.Authenticated } Logger.info("Completed pubky auth for '$pk'", context = TAG) loadProfile() + loadContacts() }.map { } } @@ -280,6 +281,50 @@ 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 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() + } + } + + // endregion + // region Profile loading suspend fun loadProfile() { @@ -604,7 +649,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) @@ -826,6 +874,9 @@ class PubkyRepo @Inject constructor( // region Sign out suspend fun signOut(): Result { + removeBitkitPaymentEndpoints() + .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } + val result = runCatching { withContext(ioDispatcher) { pubkyService.signOut() } }.recoverCatching { @@ -912,17 +963,37 @@ 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 fun requireAddableContactPublicKey(publicKey: String): String { + 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) - ?: throw PubkyContactError.InvalidFormat - if (_publicKey.value == prefixedKey) { - throw PubkyContactError.CannotAddSelf + contactValidationError(prefixedKey, allowExisting)?.let { throw it } + return checkNotNull(prefixedKey) { "Normalized pubky key is required" } + } + + private fun contactValidationError(prefixedKey: String?, allowExisting: Boolean = false): PubkyContactError? { + if (prefixedKey == null) return PubkyContactError.InvalidFormat + if (_publicKey.value == prefixedKey) return PubkyContactError.CannotAddSelf + if (!allowExisting && _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 000000000..d8de750a6 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -0,0 +1,395 @@ +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 +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 +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 +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 +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +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") + 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 +} + +@OptIn(ExperimentalTime::class) +@Suppress("LongParameterList") +@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, + private val settingsStore: SettingsStore, + private val clock: Clock, +) { + 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, + MethodId.P2tr, + MethodId.P2wpkh, + MethodId.P2sh, + MethodId.P2pkh, + ) + + 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 + + val knownMethodId = MethodId.fromRawValue(methodId) ?: return null + val payload = runCatching { + payloadJson.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 payloadJson.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.encodeToUrl()}" + } + + 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 + } + } + } + + private val publishMutex = Mutex() + + 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) + } + } + + 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 + pubkyRepo.getPaymentList(normalizedKey).getOrThrow() + .mapNotNull { parseEndpoint(it.methodId, it.endpointData) } + .associateBy { it.methodId } + .values + .sortedBy { endpoint -> payablePreferenceOrder.indexOf(endpoint.methodId) } + } + } + + private suspend fun removePublishedEndpoints() { + publishMutex.withLock { + val currentMethodIds = currentPublishedMethodIds() + managedMethodIds + .filter { it.rawValue in currentMethodIds } + .forEach { pubkyRepo.removePaymentEndpoint(it.rawValue).getOrThrow() } + clearPublicBolt11Metadata() + } + } + + private suspend fun applyPublishedEndpoints(desiredEndpoints: List) { + publishMutex.withLock { + requireCurrentPublicKey() + val desiredMethodIds = desiredEndpoints.map { it.methodId.rawValue }.toSet() + + desiredEndpoints.forEach { + pubkyRepo.setPaymentEndpoint(it.methodId.rawValue, it.rawPayload).getOrThrow() + } + + val publishedMethodIds = currentPublishedMethodIds() + managedMethodIds + .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 currentPublicKey + } + + 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 } + + 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 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 + ?: throw PublicPaykitError.InvalidPayload + 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 + + val refreshAtMillis = publicPaykitBolt11ExpiresAtMillis - publicBolt11RefreshWindow.inWholeMilliseconds + return nowMillis >= refreshAtMillis + } + + 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) + !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( + private val fixedRawValue: String? = null, + private val onchainEndpoint: String? = null, + val isOnchain: Boolean = false, + val isBitkitManaged: Boolean = false, +) { + 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 } + } +} + +@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/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 199e2ff3e..dde2ee0a4 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 4349eced4..527dff93d 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/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 6fbe4925d..977a928b8 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/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 568856ae1..338a6e1ce 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 120e30469..702e09ed4 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 @@ -18,26 +19,38 @@ 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.PaykitAndroid 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 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) } @@ -76,6 +89,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 64f2d9c65..262b99f51 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 @@ -251,6 +254,7 @@ fun ContentView( currencyViewModel.triggerRefresh() blocktankViewModel.refreshOrders() + appViewModel.refreshPublicPaykitEndpoints() } Lifecycle.Event.ON_STOP -> { @@ -979,15 +983,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 { @@ -1086,7 +1105,9 @@ private fun NavGraphBuilder.profile( ) } composableWithDefaultTransitions { + val viewModel: PayContactsViewModel = hiltViewModel() PayContactsScreen( + viewModel = viewModel, onContinue = { navController.navigateTo(Routes.Profile) { popUpTo(Routes.Home) } }, @@ -1913,6 +1934,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/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 1b6f70840..43b87cd91 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/screens/contacts/AddContactScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactScreen.kt index 5ef753e41..2059649a5 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, ) { @@ -453,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() @@ -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 2e0ef85e3..9cc2ec6d2 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,9 +16,12 @@ 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 +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 +30,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 +58,32 @@ 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) } + val hasEndpoint = loadPaymentEndpoint(profile.publicKey) + _uiState.update { + it.copy( + fetchedProfile = profile, + hasPublicPaymentEndpoint = hasEndpoint, + isLoading = false, + ) + } } .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 +97,52 @@ 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 '${PubkyPublicKeyFormat.redacted(publicKey)}'", + it, + context = TAG, + ) + } + .getOrDefault(false) + } + + 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 { + 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) + } + } + } + + 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 +174,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 000000000..1e51dfba3 --- /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 000000000..48f99efe4 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactActivityViewModel.kt @@ -0,0 +1,125 @@ +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 to.bitkit.utils.Logger +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() { + companion object { + private const val TAG = "ContactActivityViewModel" + } + + 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 { + Logger.warn( + "Failed to load contact profile for '${PubkyPublicKeyFormat.redacted(publicKey)}'", + it, + context = TAG, + ) + _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 05aee9370..1ce949906 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 @@ -51,6 +50,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() @@ -59,7 +60,7 @@ fun ContactDetailScreen( LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { - ContactDetailEffect.DeleteSuccess -> onBackClick() + is ContactDetailEffect.OpenPayment -> onPayContact(it.paymentRequest, it.publicKey) } } } @@ -69,15 +70,14 @@ 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() }, onAddTag = { viewModel.showAddTagSheet() }, onRemoveTag = { viewModel.removeTag(it) }, onDismissAddTagSheet = { viewModel.dismissAddTagSheet() }, onSaveTag = { viewModel.addTag(it) }, - onDismissDeleteDialog = { viewModel.dismissDeleteDialog() }, - onConfirmDelete = { viewModel.deleteContact() }, ) } @@ -87,15 +87,14 @@ private fun Content( onBackClick: () -> Unit, onClickEdit: () -> Unit, onClickCopy: () -> Unit, + 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 @@ -107,14 +106,16 @@ private fun Content( ) when { - uiState.isLoading && currentProfile == null -> LoadingState() + uiState.isLoading -> LoadingState() 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, onRemoveTag = onRemoveTag, ) @@ -122,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, @@ -145,10 +136,12 @@ 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, onRemoveTag: (Int) -> Unit, ) { @@ -172,10 +165,23 @@ private fun ContactBody( VerticalSpacer(24.dp) - Row( + FlowRow( horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { + if (hasPublicPaymentEndpoint) { + ActionButton( + onClick = onClickPay, + iconRes = R.drawable.ic_coins, + modifier = Modifier.testTag("ContactPay") + ) + } + ActionButton( + onClick = onClickActivity, + iconRes = R.drawable.ic_activity, + modifier = Modifier.testTag("ContactActivity") + ) ActionButton( onClick = onClickCopy, iconRes = R.drawable.ic_copy, @@ -191,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) @@ -291,15 +292,14 @@ private fun Preview() { onBackClick = {}, onClickEdit = {}, onClickCopy = {}, + 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 67ce31242..247cb763a 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,8 +21,11 @@ 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 +import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import javax.inject.Inject @@ -31,6 +34,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() { @@ -42,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() @@ -55,24 +61,28 @@ class ContactDetailViewModel @Inject constructor( 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, ) } @@ -88,6 +98,42 @@ class ContactDetailViewModel @Inject constructor( } } + private suspend fun loadPaymentEndpoint(): Boolean { + return publicPaykitRepo.hasPayablePublicEndpoint(publicKey) + .onFailure { + Logger.warn("Failed to load public Paykit endpoint for '$redactedPublicKey'", it, context = TAG) + } + .getOrDefault(false) + } + + 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 '$redactedPublicKey'", 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 -> @@ -132,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 '$publicKey'", it, context = TAG) - } - } - } - private fun persistTags(tags: List) { val profile = _uiState.value.profile ?: return viewModelScope.launch { @@ -169,7 +189,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) } } } @@ -180,10 +200,10 @@ 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 bcac9b85a..06fdce7f2 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 5f272d99c..e4ebffd09 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 af4c58d26..b7bb3709f 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 = if (uiState.isLoading) null else 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 000000000..41cc7fbb8 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt @@ -0,0 +1,113 @@ +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.SettingsData +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 +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 = resolvedSharingDefault(settings), + ) + } + } + } + + 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 = resolvedSharingDefault(settings) + 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 = syncErrorMessage(it), + ) + _uiState.update { + it.copy( + isLoading = false, + isPaymentSharingEnabled = persistedValue, + ) + } + } + } + } + + 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) + } + + private fun resolvedSharingDefault(settings: SettingsData): Boolean = + settings.sharesPublicPaykitEndpoints || !settings.hasConfirmedPublicPaykitEndpoints +} + +@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 7ff7c1c4d..10ee33dfd 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,25 +3,28 @@ 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 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,7 +48,12 @@ fun ActivityListGrouped( showFooter: Boolean = false, onAllActivityButtonClick: () -> Unit = {}, 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() @@ -97,7 +105,12 @@ fun ActivityListGrouped( placementSpec = tween(durationMillis = 300) ) ) { - ActivityRow(item, onActivityItemClick, testTag = "Activity-$index") + ActivityRow( + item = item, + onClick = onActivityItemClick, + testTag = "Activity-$index", + title = titleProvider(item) ?: contactActivityTitle(item, contacts), + ) VerticalSpacer(16.dp) } } @@ -115,7 +128,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/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt index 7ef5f4b28..8d17b90ed 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 6d7e4e028..c57009e21 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 @@ -48,6 +46,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 @@ -67,6 +66,7 @@ fun ActivityRow( item: Activity, onClick: (String) -> Unit, testTag: String, + title: String? = null, ) { val blocktankInfo by blocktankViewModel?.info?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) @@ -87,9 +87,11 @@ fun ActivityRow( is Activity.Onchain -> item.v1.confirmed } val isTransfer = item.isTransfer() - val activityListViewModel = activityListViewModel var isCpfpChild by remember { mutableStateOf(false) } + val resolvedTitle = title.takeIf { + shouldUseContactActivityTitle(item, status, isTransfer, isCpfpChild) + } LaunchedEffect(item) { isCpfpChild = if (item is Activity.Onchain && activityListViewModel != null) { @@ -109,7 +111,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) @@ -119,7 +121,8 @@ fun ActivityRow( isLightning = isLightning, status = status, isTransfer = isTransfer, - isCpfpChild = isCpfpChild + isCpfpChild = isCpfpChild, + title = resolvedTitle, ) val context = LocalContext.current val subtitleText = when (item) { @@ -162,7 +165,7 @@ fun ActivityRow( maxLines = 1, ) } - Spacer(modifier = Modifier.width(16.dp)) + HorizontalSpacer(16.dp) AmountView( item = item, prefix = amountPrefix, @@ -170,6 +173,20 @@ fun ActivityRow( } } +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( @@ -178,7 +195,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)) @@ -286,7 +309,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/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ContactActivityTitle.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ContactActivityTitle.kt new file mode 100644 index 000000000..283c2df8e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ContactActivityTitle.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/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 0b93407ac..a41243741 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 89f42eb0f..4c1913954 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/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 9b9431d65..bdfef1bf5 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/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 47294aef0..ca1f462ec 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -44,8 +44,10 @@ class WipeWalletUseCase @Inject constructor( backupRepo.setWiping(true) backupRepo.reset() - keychain.wipe() + pubkyRepo.removeBitkitPaymentEndpoints() + .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } pubkyRepo.wipeLocalState() + keychain.wipe() firebaseMessaging.deleteToken() coreService.wipeData() diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 3623bf0b7..0eae13da9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -27,7 +27,9 @@ 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 import to.bitkit.utils.Logger import javax.inject.Inject @@ -37,6 +39,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 +53,9 @@ class ActivityListViewModel @Inject constructor( private val _latestActivities = MutableStateFlow?>(null) val latestActivities = _latestActivities.asStateFlow() + val contacts: StateFlow> = + pubkyRepo.contacts.map { it.toImmutableList() }.stateInScope(persistentListOf()) + val availableTags: StateFlow> = activityRepo.state.map { it.tags }.stateInScope(persistentListOf()) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7c1ad3b4e..97099a267 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,9 @@ 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 import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -120,6 +124,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 @@ -149,6 +154,8 @@ 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 @OptIn(ExperimentalTime::class) @@ -178,6 +185,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 +239,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 +336,8 @@ class AppViewModel @Inject constructor( } } observeLdkNodeEvents() + observePublicPaykitEndpoints() + observePublicPaykitInvoiceExpiry() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -360,6 +374,48 @@ class AppViewModel @Inject constructor( } } + @OptIn(FlowPreview::class) + private fun observePublicPaykitEndpoints() { + viewModelScope.launch { + walletRepo.walletState + .map { it.onchainAddress } + .distinctUntilChanged() + .debounce(PUBLIC_PAYKIT_SYNC_DEBOUNCE) + .collect { refreshPublicPaykitEndpointsIfEnabled() } + } + } + + 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() } + } + + 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 @@ -404,6 +460,7 @@ class AppViewModel @Inject constructor( private suspend fun handleChannelReady(event: Event.ChannelReady) { transferRepo.syncTransferStates() walletRepo.syncBalances() + refreshPublicPaykitEndpointsIfEnabled() notifyChannelReady(event) } @@ -420,6 +477,7 @@ class AppViewModel @Inject constructor( } transferRepo.syncTransferStates() walletRepo.syncBalances() + refreshPublicPaykitEndpointsIfEnabled() } private suspend fun createTransferForCounterpartyClose(channelId: String, isForceClose: Boolean) { @@ -633,6 +691,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() @@ -646,6 +705,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) } @@ -654,6 +721,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() @@ -1276,7 +1344,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 +1364,23 @@ class AppViewModel @Inject constructor( ) } + fun openContactPayment(paymentRequest: String, publicKey: String) { + synchronized(contactPaymentContextLock) { + activeContactPaymentContext = ContactPaymentContext(publicKey) + } + onScanResult(paymentRequest) + } + + fun preserveContactPaymentContext(paymentHash: String) { + synchronized(contactPaymentContextLock) { + val context = activeContactPaymentContext + if (context != null) { + pendingContactPaymentContexts[paymentHash] = context + activeContactPaymentContext = null + } + } + } + private suspend fun handleScan( result: String, routePubkyKeys: Boolean, @@ -1314,10 +1399,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 +1417,7 @@ class AppViewModel @Inject constructor( ) if (route != null) { + clearActiveContactPaymentContext() mainScreenEffect(MainScreenEffect.Navigate(route)) return@withContext } @@ -1340,26 +1428,55 @@ 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() + } + + fun clearActiveContactPaymentContext() { + synchronized(contactPaymentContextLock) { + activeContactPaymentContext = null + } + } + + private fun clearPendingContactPaymentContext(paymentHash: String) { + synchronized(contactPaymentContextLock) { + pendingContactPaymentContexts.remove(paymentHash) + } + } + + 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) } @@ -1370,6 +1487,7 @@ class AppViewModel @Inject constructor( description = context.getString(R.string.wallet__error_invalid_bitcoin_address), testTag = "InvalidAddressToast", ) + clearActiveContactPaymentContext() return } @@ -1380,6 +1498,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 +1546,7 @@ class AppViewModel @Inject constructor( description = context.getString(R.string.other__pay_insufficient_savings_description), testTag = "InsufficientSavingsToast", ) + clearActiveContactPaymentContext() return } @@ -1444,6 +1564,7 @@ class AppViewModel @Inject constructor( .replace("{amount}", formatMoneyValue(shortfall)), testTag = "InsufficientSavingsToast", ) + clearActiveContactPaymentContext() return } @@ -1470,6 +1591,7 @@ class AppViewModel @Inject constructor( description = context.getString(R.string.other__scan__error__expired), testTag = "ExpiredLightningToast", ) + clearActiveContactPaymentContext() return } @@ -1486,6 +1608,7 @@ class AppViewModel @Inject constructor( .replace("{amount}", formatMoneyValue(shortfall)), testTag = "InsufficientSpendingToast", ) + clearActiveContactPaymentContext() return } @@ -1530,6 +1653,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 } @@ -1687,6 +1811,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 @@ -1915,6 +2041,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 } @@ -1944,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( @@ -1954,7 +2081,7 @@ class AppViewModel @Inject constructor( lightningRepo.createInvoice( amountSats = withdrawAmountSats, description = lnurl.data.defaultDescription, - expirySeconds = 3600u, + expirySeconds = Defaults.bolt11InvoiceExpirySeconds, ) }.getOrNull() @@ -2332,6 +2459,7 @@ class AppViewModel @Inject constructor( else -> _currentSheet.update { null } } + clearActiveContactPaymentContext() } // endregion @@ -2491,16 +2619,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 pendingContext = pendingContactPaymentContexts.remove(paymentHashOrTxId) + val context = pendingContext ?: activeContactPaymentContext + if (pendingContext == null && 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") @@ -2607,6 +2757,8 @@ 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 val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val PUBKYAUTH_SCHEME = "pubkyauth" } } @@ -2655,6 +2807,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/java/to/bitkit/viewmodels/ProbingToolViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt index 3f5533659..372269bc8 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/main/res/drawable/ic_activity.xml b/app/src/main/res/drawable/ic_activity.xml new file mode 100644 index 000000000..039da09cc --- /dev/null +++ b/app/src/main/res/drawable/ic_activity.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9a7ae2ec1..2b057b492 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. @@ -533,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 @@ -880,6 +887,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/models/MSatTest.kt b/app/src/test/java/to/bitkit/models/MSatTest.kt index fc17e8ebc..e5aa981c7 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/models/PubkyPublicKeyFormatTest.kt b/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt index e07c1c551..60efde08c 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/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index a6249d3cd..f148b1635 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/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 4c0fb52f0..877e23dd7 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -200,11 +200,14 @@ class LightningRepoTest : BaseUnitTest() { lightningService.receive( sat = 100uL, description = "test", - expirySecs = 3600u + expirySecs = 86_400u, ) ).thenReturn(testInvoice) - val result = sut.createInvoice(amountSats = 100uL, description = "test", expirySeconds = 3600u) + val result = sut.createInvoice( + amountSats = 100uL, + description = "test", + ) assertTrue(result.isSuccess) assertEquals(testInvoice, result.getOrNull()) } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 67acbd04f..c377f15a2 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -4,6 +4,8 @@ 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.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Before @@ -19,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 @@ -50,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() } @@ -63,6 +76,7 @@ class PubkyRepoTest : BaseUnitTest() { keychain = keychain, imageLoader = imageLoader, pubkyStore = pubkyStore, + settingsStore = settingsStore, httpClient = mock(), ) @@ -105,6 +119,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 +138,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 +148,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 @@ -240,6 +260,60 @@ 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) + 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 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.isSuccess) + assertNull(sut.publicKey.value) + assertFalse(sut.isAuthenticated.value) + verifyBlocking(pubkyService) { signOut() } + verifyBlocking(keychain, atLeastOnce()) { delete(Keychain.Key.PAYKIT_SESSION.name) } + } + @Test fun `signOut should evict pubky images from caches`() = test { authenticateForTesting() @@ -465,7 +539,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 +549,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 @@ -916,10 +990,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 new file mode 100644 index 000000000..0e773415d --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -0,0 +1,465 @@ +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.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 +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 + private const val PUBLIC_BOLT11_EXPIRY_SECONDS = 86_400u + } + + 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 { + walletState.value = WalletState( + onchainAddress = "bc1ptest", + bolt11 = "lnbc1user", + ) + stubPublicInvoice("lnbc1public", byteArrayOf(1, 2, 3)) + whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( + Result.success( + listOf( + paymentEntry(MethodId.Bolt11, "lnbc1old"), + paymentEntry(MethodId.Lnurl, "lnurl1external"), + paymentEntry(MethodId.P2pkh, "1obsolete"), + ), + ), + ) + + 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.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 bitkit managed endpoints and preserves lnurl`() = test { + setSettings( + SettingsData( + publicPaykitBolt11 = "lnbc1old", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + ), + ) + + whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn( + Result.success( + listOf( + paymentEntry(MethodId.Bolt11, "lnbc1old"), + paymentEntry(MethodId.Lnurl, "lnurl1external"), + paymentEntry(MethodId.P2tr, "bc1pold"), + ), + ), + ) + + val result = sut.syncPublishedEndpoints(publish = false) + + assertTrue(result.isSuccess) + assertEquals("", settingsFlow.value.publicPaykitBolt11) + verify(pubkyRepo).removePaymentEndpoint(MethodId.Bolt11.rawValue) + verify(pubkyRepo).removePaymentEndpoint(MethodId.P2tr.rawValue) + verify(pubkyRepo, never()).removePaymentEndpoint(MethodId.Lnurl.rawValue) + } + + @Test + fun `syncCurrentPublishedEndpoints reuses fresh public bolt11`() = test { + setSettings( + SettingsData( + publicPaykitBolt11 = "lnbc1cached", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + ), + ) + + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(pubkyRepo.getPaymentList("pubkyself")).thenReturn(Result.success(emptyList())) + + val result = sut.syncCurrentPublishedEndpoints() + + assertTrue(result.isSuccess) + 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 { + setSettings( + SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitBolt11 = "lnbc1old", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + ), + ) + + stubPublicInvoice("lnbc1new", byteArrayOf(4, 5, 6)) + 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 { + setSettings( + SettingsData( + sharesPublicPaykitEndpoints = true, + publicPaykitBolt11 = "lnbc1old", + publicPaykitBolt11PaymentHash = "010203", + publicPaykitBolt11ExpiresAtMillis = freshExpiryMillis(), + ), + ) + + val result = sut.refreshPublishedBolt11ForPayment("unrelated") + + assertTrue(result.isSuccess) + 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 { + publicKey.value = null + whenever(pubkyRepo.currentPublicKey()).thenReturn(Result.success(null)) + walletState.value = WalletState(onchainAddress = "bc1ptest") + + val error = sut.syncCurrentPublishedEndpoints().exceptionOrNull() + + assertEquals(PublicPaykitError.SessionNotActive, error) + 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( + 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 = 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( + 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 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( + 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 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())) + } + + @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 `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")) + 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"}""", + ) + + @Suppress("LongParameterList") + private fun createRepo( + 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, + 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 setSettings(settings: SettingsData) { + settingsFlow.value = settings + } + + private fun freshExpiryMillis() = NOW_MILLIS + 1.hours.inWholeMilliseconds + + private suspend fun stubPublicInvoice( + bolt11: String, + paymentHash: ByteArray, + ) { + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever( + lightningRepo.createInvoice( + amountSats = null, + description = "", + expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS, + ) + ).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 = 0uL, + timestampSeconds = 0u, + expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS.toULong(), + isExpired = false, + description = "", + networkType = NetworkType.REGTEST, + payeeNodeId = null, + ) +} 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 143d3a217..b1f632d65 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 da4b5c398..1b76dffdc 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/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 2e50351bf..d13ac24a5 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -47,6 +47,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 +91,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 +136,23 @@ class WipeWalletUseCaseTest : BaseUnitTest() { verify(backupRepo).setWiping(false) } + @Test + fun `invoke should continue 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.isSuccess) + verify(pubkyRepo).wipeLocalState() + verify(keychain).wipe() + verify(backupRepo).setWiping(false) + } + @Test fun `invoke should return failure when lightningRepo wipeStorage fails`() = runTest { val error = RuntimeException("Lightning wipe failed") diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index df4f5568b..900101fe9 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 @@ -9,9 +12,12 @@ 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.clearInvocations 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 @@ -24,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 @@ -31,8 +38,10 @@ 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 import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState @@ -41,11 +50,14 @@ 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 import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -73,25 +85,37 @@ 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() private val balanceState = MutableStateFlow(BalanceState()) + private val settingsData = MutableStateFlow(SettingsData()) + private val walletState = MutableStateFlow(WalletState()) + 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(settingsStore.data).thenReturn(flowOf(SettingsData())) + whenever(walletRepo.walletState).thenReturn(walletState) + whenever(walletRepo.walletExists()).thenReturn(true) + 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) @@ -106,41 +130,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, - 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)) @@ -251,6 +276,127 @@ 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 `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 `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" + 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() + 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) @@ -304,6 +450,62 @@ 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") + whenever { publicPaykitRepo.syncCurrentPublishedEndpoints() }.thenReturn(Result.success(Unit)) + } + + @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) + } + + 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") + 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/changelog.d/next/924.added.md b/changelog.d/next/924.added.md new file mode 100644 index 000000000..c8fbe3159 --- /dev/null +++ b/changelog.d/next/924.added.md @@ -0,0 +1 @@ +Support public Paykit contact payments. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27084a1ec..6a1a474ce 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-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" } diff --git a/scripts/collect-changelog.sh b/scripts/collect-changelog.sh index 2f8772668..c6ee914ce 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 0e47ca2f9..24630db72 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 = {