diff --git a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt index ee7570a3c0..80b4c20c8e 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt @@ -153,7 +153,7 @@ class DrawerMenuWidgetsTest { } @Test - fun paymentRequestsIsAvailableFromDrawerWhenPaykitIsEnabled() { + fun subscriptionsIsTheOnlyPaykitEntryInDrawerWhenPaykitIsEnabled() { composeTestRule.setContent { val navController = rememberNavController() val drawerState = rememberDrawerState(DrawerValue.Open) @@ -166,8 +166,8 @@ class DrawerMenuWidgetsTest { composable { Text("Home", modifier = Modifier.testTag("HomeRoute")) } - composable { - Text("Payment Requests", modifier = Modifier.testTag("PaymentRequestsRoute")) + composable { + Text("Subscriptions", modifier = Modifier.testTag("SubscriptionsRoute")) } } DrawerMenu( @@ -182,14 +182,13 @@ class DrawerMenuWidgetsTest { } } - composeTestRule.onNodeWithText("REQUESTS").assertIsDisplayed() - composeTestRule.onNodeWithTag("DrawerPaymentRequests").performClick() + composeTestRule.onNodeWithTag("DrawerSubscriptions").performClick() - composeTestRule.onNodeWithTag("PaymentRequestsRoute").assertIsDisplayed() + composeTestRule.onNodeWithTag("SubscriptionsRoute").assertIsDisplayed() } @Test - fun paymentRequestsIsHiddenFromDrawerWhenPaykitIsDisabled() { + fun paykitEntriesAreHiddenFromDrawerWhenPaykitIsDisabled() { composeTestRule.setContent { val navController = rememberNavController() val drawerState = rememberDrawerState(DrawerValue.Open) @@ -207,7 +206,7 @@ class DrawerMenuWidgetsTest { } } - composeTestRule.onNodeWithTag("DrawerPaymentRequests").assertDoesNotExist() + composeTestRule.onNodeWithTag("DrawerSubscriptions").assertDoesNotExist() } } diff --git a/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt index c0be748197..b84401db9d 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.testTag @@ -84,4 +85,39 @@ class SheetHostTest { assertEquals(0, dismissCount) assertEquals(0, backgroundClickCount) } + + @Test + fun programmaticHideDoesNotInvokeDismissalCallback() { + val shouldExpand = mutableStateOf(true) + val visibilityKey = mutableStateOf("subscription") + var dismissCount = 0 + composeTestRule.setContent { + AppThemeSurface { + SheetHost( + shouldExpand = shouldExpand.value, + onDismiss = { dismissCount++ }, + visibilityKey = visibilityKey.value, + sheets = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(320.dp) + .testTag("ProgrammaticSheet") + ) + }, + content = { Box(Modifier.fillMaxSize()) }, + ) + } + } + composeTestRule.onNodeWithTag("ProgrammaticSheet").assertIsDisplayed() + + composeTestRule.runOnIdle { + shouldExpand.value = false + visibilityKey.value = null + } + composeTestRule.mainClock.advanceTimeBy(1_000) + composeTestRule.waitForIdle() + + assertEquals(0, dismissCount) + } } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt index 026df0fcc9..1cdd2d18e1 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt @@ -22,6 +22,7 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.viewmodels.AmountInputViewModel import kotlin.time.ExperimentalTime import kotlin.time.Instant +import kotlin.test.assertEquals @ComposeUi class CreatePaymentRequestScreenTest { @@ -29,50 +30,67 @@ class CreatePaymentRequestScreenTest { val composeTestRule = createComposeRule() @Test - fun detailsShowsAmountNoteExpiryAndContinue() { + fun detailsShowsAmountNoteExpiryAndSend() { composeTestRule.setContent { AppThemeSurface { PaymentRequestDetailsContent( - amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), initialDraft = draft, + contact = PubkyProfile.placeholder(target.publicKey), + isCreating = false, onBack = {}, - onContinue = {}, + onEditAmount = {}, + onSend = {}, ) } } - composeTestRule.onNodeWithTag("PaymentRequestAmountField").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestNote").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestExpiryWeek").assertIsDisplayed() - composeTestRule.onNodeWithTag("PaymentRequestAmountContinue").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestSend").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestNumberPad").assertDoesNotExist() + } - composeTestRule.onNodeWithTag("PaymentRequestEditAmount").performClick() + @Test + fun amountShowsNumberPadAndContinue() { + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestAmountContent( + amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), + initialDraft = draft, + contact = PubkyProfile.placeholder(target.publicKey), + onBack = {}, + onContinue = {}, + ) + } + } + composeTestRule.onNodeWithTag("PaymentRequestAmountField").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestNumberPad").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestAmountContinue").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestNote").assertDoesNotExist() } @Test - fun recipientShowsEligibleContactAndSendAction() { + fun recipientShowsEligibleContactAndAdvancesOnSelection() { + var selectedTarget: PaykitPaymentRequestTarget? = null composeTestRule.setContent { AppThemeSurface { PaymentRequestRecipientContent( targets = persistentListOf(target), contacts = persistentListOf(PubkyProfile.placeholder(target.publicKey)), - isCreating = false, - onEditExpiration = {}, + onBack = {}, onPaste = { target.publicKey }, - onSend = {}, + onSelected = { selectedTarget = it }, ) } } composeTestRule.onNodeWithTag("PaymentRequestContact${target.publicKey}").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestRecipientSearch").assertIsDisplayed() - composeTestRule.onNodeWithTag("PaymentRequestEditExpiration").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestRecipientPaste", useUnmergedTree = true).assertIsDisplayed() - composeTestRule.onNodeWithTag("PaymentRequestSend").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestSend").assertDoesNotExist() + composeTestRule.onNodeWithTag("PaymentRequestContact${target.publicKey}").performClick() + assertEquals(target, selectedTarget) composeTestRule.onNodeWithTag("PaymentRequestRecipientSearch").performTextInput("not this contact") @@ -81,11 +99,12 @@ class CreatePaymentRequestScreenTest { @Test fun sentShowsSuccessSurface() { + val contact = PubkyProfile.forDisplay(target.publicKey, "Anna", imageUrl = null) composeTestRule.setContent { AppThemeSurface { PaymentRequestSentContent( request = request.copy(deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent), - contact = PubkyProfile.placeholder(target.publicKey), + contact = contact, onDone = {}, ) } @@ -94,7 +113,8 @@ class CreatePaymentRequestScreenTest { composeTestRule.onNodeWithTag("PaymentRequestSent").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestSentCheck").assertIsDisplayed() composeTestRule.onNodeWithText("PAYMENT REQUESTED").assertIsDisplayed() - composeTestRule.onNodeWithText("Waiting for payment").assertIsDisplayed() + composeTestRule.onNodeWithText("Anna").assertIsDisplayed() + composeTestRule.onNodeWithText("Dinner").assertIsDisplayed() } private val draft = PaykitPaymentRequestDraft( diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index 436b703804..a1b6e62917 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -13,9 +13,15 @@ import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PaykitBillingPeriod import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestDirection +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionMetadata +import to.bitkit.repositories.PaykitSubscriptionRecurrence import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.theme.AppThemeSurface import kotlin.time.Clock @@ -36,10 +42,12 @@ class PaymentRequestsScreenTest { PaymentRequestsSheetContent( requests = persistentListOf(request), contacts = persistentListOf(), + subscriptions = persistentListOf(), onNotNow = {}, onSeeAll = {}, onPay = {}, - onReject = { Result.success(Unit) }, + onDismiss = { Result.success(Unit) }, + onDetails = {}, ) } } @@ -51,6 +59,37 @@ class PaymentRequestsScreenTest { composeTestRule.onNodeWithText("Dismiss").assertIsDisplayed() } + @Test + fun recurringQueueCardShowsProviderThenSubscriptionName() { + val contact = PubkyProfile.forDisplay(request().counterparty, "Coffee House", imageUrl = null) + val subscription = subscription(note = "Weekly coffee") + val recurringRequest = request(id = subscription.paymentRequestId).copy( + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2027-01-15T08:00:00Z"), + endsAt = Instant.parse("2027-01-22T08:00:00Z"), + ), + ) + + composeTestRule.setContent { + PaymentRequestsTestSurface { + PaymentRequestsSheetContent( + requests = persistentListOf(recurringRequest), + contacts = persistentListOf(contact), + subscriptions = persistentListOf(subscription), + onNotNow = {}, + onSeeAll = {}, + onPay = {}, + onDismiss = { Result.success(Unit) }, + onDetails = {}, + ) + } + } + + composeTestRule.onNodeWithText("Coffee House").assertIsDisplayed() + composeTestRule.onNodeWithText("Weekly coffee").assertIsDisplayed() + } + @Test fun historyGroupsCompletedRequestsAndKeepsActiveOutgoingRequests() { val now = Clock.System.now() @@ -70,11 +109,13 @@ class PaymentRequestsScreenTest { requests = persistentListOf(outgoing, accepted), pending = persistentListOf(), contacts = persistentListOf(), + subscriptions = persistentListOf(), canRequestPayment = true, onBack = {}, onRequestPayment = {}, onPay = {}, - onReject = { Result.success(Unit) }, + onDismiss = { Result.success(Unit) }, + onDetails = {}, ) } } @@ -95,11 +136,13 @@ class PaymentRequestsScreenTest { requests = persistentListOf(), pending = persistentListOf(), contacts = persistentListOf(), + subscriptions = persistentListOf(), canRequestPayment = true, onBack = {}, onRequestPayment = {}, onPay = {}, - onReject = { Result.success(Unit) }, + onDismiss = { Result.success(Unit) }, + onDetails = {}, ) } } @@ -120,11 +163,13 @@ class PaymentRequestsScreenTest { requests = persistentListOf(), pending = persistentListOf(), contacts = persistentListOf(), + subscriptions = persistentListOf(), canRequestPayment = false, onBack = {}, onRequestPayment = {}, onPay = {}, - onReject = { Result.success(Unit) }, + onDismiss = { Result.success(Unit) }, + onDetails = {}, ) } } @@ -132,7 +177,7 @@ class PaymentRequestsScreenTest { composeTestRule.onNodeWithTag("PaymentRequestCreate").assertDoesNotExist() } - private fun request(id: String) = PaykitPaymentRequest( + private fun request(id: String = "request") = PaykitPaymentRequest( paymentRequestId = id, counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", counterpartyReceiverPath = "bitkit/wallet", @@ -143,6 +188,28 @@ class PaymentRequestsScreenTest { expiresAt = null, acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), ) + + private fun subscription(note: String) = PaykitSubscription( + paymentRequestId = "subscription", + counterparty = request().counterparty, + counterpartyReceiverPath = "bitkit/wallet", + amountValue = "0.00025", + amountSats = 25_000uL, + note = note, + createdAt = Instant.parse("2027-01-15T08:00:00Z"), + proposalExpiresAt = null, + recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Week, + startsAt = Instant.parse("2027-01-15T08:00:00Z"), + anchor = Instant.parse("2027-01-15T08:00:00Z"), + endsAt = null, + ), + metadata = PaykitSubscriptionMetadata(description = null, benefits = emptyList()), + acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paidPeriods = emptyList(), + ) } @Composable diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt new file mode 100644 index 0000000000..da6b33ce30 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -0,0 +1,60 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.screens.paymentrequests.PaymentRequestsContent +import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@ComposeUi +class SubscriptionsScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun paymentsTabShowsEligibleOneTimeRequestAction() { + var requestedPayment = false + composeTestRule.setContent { + AppThemeSurface { + SubscriptionsContent( + subscriptions = persistentListOf(), + contacts = persistentListOf(), + acceptedAt = { null }, + now = Instant.parse("2027-01-15T08:00:00Z"), + onBack = {}, + initialTab = SubscriptionTab.Payments, + pendingPaymentRequestCount = 0, + onSubscription = {}, + paymentsContent = { + PaymentRequestsContent( + requests = persistentListOf(), + pending = persistentListOf(), + contacts = persistentListOf(), + subscriptions = persistentListOf(), + canRequestPayment = true, + onBack = {}, + onRequestPayment = { requestedPayment = true }, + onPay = {}, + onDismiss = { Result.success(Unit) }, + onDetails = {}, + showsNavigationBar = false, + ) + }, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestCreate").assertIsDisplayed().performClick() + assertTrue(requestedPayment) + } +} diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index c70c1c467b..8d2cddf6c4 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1340,6 +1340,8 @@ class LightningRepo @Inject constructor( channelId: String? = null, isMaxAmount: Boolean = false, tags: List = emptyList(), + beforeSendAttempt: suspend () -> Unit = {}, + onBroadcast: suspend (Txid) -> Unit = {}, ): Result = executeWhenNodeRunning("sendOnChain") { require(address.isNotEmpty()) { "Send address cannot be empty" } @@ -1364,7 +1366,9 @@ class LightningRepo @Inject constructor( Logger.debug("UTXOs selected to spend: $utxosForSend", context = TAG) + beforeSendAttempt() val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) + onBroadcast(txId) val preActivityMetadata = PreActivityMetadata( walletId = WalletScope.default, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index cf62ca5c1f..74ab5d2b3e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -1,6 +1,8 @@ package to.bitkit.repositories import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -30,7 +32,14 @@ import javax.inject.Singleton @Serializable enum class PaykitPaymentProofKind(val type: String) { Lightning("bitcoin-bolt11-preimage"), - Onchain("bitcoin-onchain-txid"), + Onchain("bitcoin-onchain-txid"); + + companion object { + fun fromPaymentEndpointIdentifier(identifier: String): PaykitPaymentProofKind? { + val method = MethodId.fromRawValue(identifier) ?: return null + return if (method.isOnchain) Onchain else Lightning + } + } } @Serializable @@ -39,15 +48,58 @@ data class PendingPaykitPaymentProof( val requestId: PaykitPaymentRequestId, val paymentEndpointIdentifier: String, val kind: PaykitPaymentProofKind, + val paymentStarted: Boolean = false, val paymentIdentifier: String? = null, val proofData: String? = null, + val billingPeriod: PaykitBillingPeriod? = null, + val onchainAddress: String? = null, + val onchainAmountSats: ULong? = null, + val onchainMatchingTransactionIdsBeforeAttempt: Set = emptySet(), +) + +data class PaykitOnchainPaymentProofResolution( + val identity: String, + val requestId: PaykitPaymentRequestId, + val transactionId: String, ) @Singleton +class PaykitOnchainPaymentProofLookup @Inject constructor( + private val lightningRepo: LightningRepo, + private val activityRepo: ActivityRepo, +) { + suspend fun existingTransactionIds(address: String, amountSats: ULong): Set = + matchingTransactionIds(address, amountSats).mapTo(mutableSetOf(), String::lowercase) + + suspend fun transactionId(address: String, amountSats: ULong, excluding: Set): String? = + matchingTransactionIds(address, amountSats).lastOrNull { it.lowercase() !in excluding } + + private suspend fun matchingTransactionIds(address: String, amountSats: ULong): List = buildList { + lightningRepo.getPayments().getOrThrow().forEach { payment -> + val transactionId = payment.onchainTransactionIdForProofLookup() ?: return@forEach + val details = activityRepo.getTransactionDetails(transactionId).getOrNull() + if (details?.outputs?.any { + it.scriptpubkeyAddress == address && it.value >= 0 && it.value.toULong() == amountSats + } == true + ) { + add(transactionId) + } + } + } + + private fun PaymentDetails.onchainTransactionIdForProofLookup(): String? { + if (direction != PaymentDirection.OUTBOUND || status == PaymentStatus.FAILED) return null + return (kind as? PaymentKind.Onchain)?.txid + } +} + +@Singleton +@Suppress("TooManyFunctions") class PaykitPaymentProofRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val paykitSdkService: PaykitSdkService, private val lightningRepo: LightningRepo, + private val onchainPaymentLookup: PaykitOnchainPaymentProofLookup, private val store: PaykitPaymentProofStore, ) { companion object { @@ -56,6 +108,8 @@ class PaykitPaymentProofRepo @Inject constructor( } private val operationMutex = Mutex() + private val _onchainPaymentResolution = MutableStateFlow(null) + val onchainPaymentResolution = _onchainPaymentResolution.asStateFlow() suspend fun prepare( request: PaykitPaymentRequest, @@ -65,13 +119,12 @@ class PaykitPaymentProofRepo @Inject constructor( runSuspendCatching { operationMutex.withLock { val proof = pendingProof(request, paymentEndpointIdentifier, kind) - val proofs = loadProofs() - .filterNot { - PubkyPublicKeyFormat.matches(it.identity, proof.identity) && - it.requestId == request.id && - it.paymentIdentifier == null && - it.proofData == null - } + + val currentProofs = loadProofs() + if (currentProofs.any { it.isStartedFor(proof.identity, request.id) }) { + throw PaykitPaymentRequestError.OperationInProgress + } + val proofs = currentProofs + .filterNot { it.isUnstartedFor(proof.identity, request.id) } + proof persist(proofs) } @@ -82,21 +135,56 @@ class PaykitPaymentProofRepo @Inject constructor( withContext(ioDispatcher) { runSuspendCatching { if (!paymentHash.isHex(HASH_BYTE_COUNT)) throw PaykitPaymentRequestError.RequestUnavailable + val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable operationMutex.withLock { val proofs = loadProofs().toMutableList() val index = proofs.indexOfLast { - it.requestId == request.id && + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == request.id && it.kind == PaykitPaymentProofKind.Lightning && + !it.paymentStarted && it.paymentIdentifier == null && it.proofData == null } if (index < 0) throw PaykitPaymentRequestError.RequestUnavailable - proofs[index] = proofs[index].copy(paymentIdentifier = paymentHash.lowercase()) + proofs[index] = proofs[index].copy( + paymentStarted = true, + paymentIdentifier = paymentHash.lowercase(), + ) persist(proofs) } }.onFailure { Logger.warn("Failed to associate a Paykit Lightning payment proof", it, context = TAG) } } + suspend fun markOnchainPaymentStarted( + request: PaykitPaymentRequest, + address: String, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable + val existingTransactionIds = onchainPaymentLookup.existingTransactionIds(address, request.amountSats) + operationMutex.withLock { + val proofs = loadProofs().toMutableList() + val index = proofs.indexOfLast { + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Onchain && + !it.paymentStarted && + it.paymentIdentifier == null && + it.proofData == null + } + if (index < 0) throw PaykitPaymentRequestError.RequestUnavailable + proofs[index] = proofs[index].copy( + paymentStarted = true, + onchainAddress = address, + onchainAmountSats = request.amountSats, + onchainMatchingTransactionIdsBeforeAttempt = existingTransactionIds, + ) + persist(proofs) + } + }.onFailure { Logger.warn("Failed to mark a Paykit on-chain payment as started", it, context = TAG) } + } + suspend fun completeLightningPayment(paymentHash: String, preimage: String?) = withContext(ioDispatcher) { if (preimage == null) return@withContext if (!preimage.matchesPaymentHash(paymentHash)) { @@ -141,12 +229,18 @@ class PaykitPaymentProofRepo @Inject constructor( return@withContext } + val identity = currentIdentity() ?: return@withContext + val fallbackProof = runSuspendCatching { + pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain) + }.getOrNull() operationMutex.withLock { val completion = runSuspendCatching { val proofs = loadProofs().toMutableList() val index = proofs.indexOfLast { - it.requestId == request.id && + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == request.id && it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && it.paymentIdentifier == null && it.proofData == null } @@ -157,6 +251,11 @@ class PaykitPaymentProofRepo @Inject constructor( ) proofs[index] = proof persistAndSubmit(listOf(proof), proofs) + _onchainPaymentResolution.value = PaykitOnchainPaymentProofResolution( + identity = proof.identity, + requestId = request.id, + transactionId = txid.lowercase(), + ) } completion.onFailure { Logger.warn( @@ -165,14 +264,19 @@ class PaykitPaymentProofRepo @Inject constructor( context = TAG, ) } - if (completion.isFailure) { - runSuspendCatching { - val proof = pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain).copy( - paymentIdentifier = txid.lowercase(), - proofData = txid.lowercase(), - ) - submitReady(proof) - }.onFailure { Logger.warn("Failed to complete a Paykit on-chain payment proof", it, context = TAG) } + if (completion.isFailure && fallbackProof != null) { + val proof = fallbackProof.copy( + paymentStarted = true, + paymentIdentifier = txid.lowercase(), + proofData = txid.lowercase(), + ) + runSuspendCatching { submitReady(proof) } + .onFailure { Logger.warn("Failed to complete a Paykit on-chain payment proof", it, context = TAG) } + _onchainPaymentResolution.value = PaykitOnchainPaymentProofResolution( + identity = proof.identity, + requestId = request.id, + transactionId = txid.lowercase(), + ) } } } @@ -181,8 +285,56 @@ class PaykitPaymentProofRepo @Inject constructor( it.kind == PaykitPaymentProofKind.Lightning && it.paymentIdentifier.equals(paymentHash, ignoreCase = true) } - suspend fun cancelPreparation(request: PaykitPaymentRequest) = removeProofs { - it.requestId == request.id && it.paymentIdentifier == null && it.proofData == null + suspend fun failOnchainPayment(request: PaykitPaymentRequest) { + val identity = currentIdentity() ?: return + removeProofs { + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && + it.paymentIdentifier == null && + it.proofData == null + } + } + + suspend fun cancelPreparation(request: PaykitPaymentRequest) { + val identity = currentIdentity() ?: return + removeProofs { + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == request.id && + !it.paymentStarted && + it.paymentIdentifier == null && + it.proofData == null + } + } + + suspend fun protectedRequestIdsForSubscriptionCancellation( + identity: String, + subscriptionId: PaykitSubscriptionId, + ): Result> = withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + val proofs = loadProofs() + val belongsToSubscription: (PendingPaykitPaymentProof) -> Boolean = { + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId.billingPeriodStartsAt != null && + it.requestId.paymentRequestId == subscriptionId.paymentRequestId && + it.requestId.counterparty == subscriptionId.counterparty && + it.requestId.counterpartyReceiverPath == subscriptionId.counterpartyReceiverPath + } + val protectedRequestIds = proofs.filter(belongsToSubscription) + .filter { it.paymentStarted || it.paymentIdentifier != null || it.proofData != null } + .mapTo(mutableSetOf()) { it.requestId } + val remainingProofs = proofs.filter { + !belongsToSubscription(it) || + it.paymentStarted || + it.paymentIdentifier != null || + it.proofData != null + } + if (remainingProofs != proofs) persist(remainingProofs) + protectedRequestIds + } + }.onFailure { Logger.warn("Failed to prepare Paykit subscription cancellation", it, context = TAG) } } suspend fun reconcile() = withContext(ioDispatcher) { @@ -208,12 +360,19 @@ class PaykitPaymentProofRepo @Inject constructor( proof: PendingPaykitPaymentProof, payments: List, ) { - if (proof.proofData != null) { - submitReady(proof) - return + when { + proof.proofData != null -> submitReady(proof) + proof.kind == PaykitPaymentProofKind.Onchain && proof.paymentStarted -> reconcileOnchainProof(proof) + proof.kind == PaykitPaymentProofKind.Lightning -> reconcileLightningProof(proof, payments) } + } + + private suspend fun reconcileLightningProof( + proof: PendingPaykitPaymentProof, + payments: List, + ) { val paymentHash = proof.paymentIdentifier - if (proof.kind != PaykitPaymentProofKind.Lightning || paymentHash == null) return + if (paymentHash == null) return val payment = payments.firstOrNull { it.direction == PaymentDirection.OUTBOUND && it.id.equals(paymentHash, ignoreCase = true) } ?: return @@ -238,24 +397,59 @@ class PaykitPaymentProofRepo @Inject constructor( } } - private suspend fun submitReady(proof: PendingPaykitPaymentProof) { - val proofData = proof.proofData ?: return + private suspend fun reconcileOnchainProof(proof: PendingPaykitPaymentProof) { + val address = proof.onchainAddress ?: return + val amountSats = proof.onchainAmountSats ?: return + val txid = onchainPaymentLookup.transactionId( + address, + amountSats, + excluding = proof.onchainMatchingTransactionIdsBeforeAttempt, + ) ?: return + if (!txid.isHex(HASH_BYTE_COUNT)) return + + val proofs = loadProofs().toMutableList() + val index = proofs.indexOf(proof) + if (index < 0) return + val completed = proof.copy(paymentIdentifier = txid.lowercase(), proofData = txid.lowercase()) + proofs[index] = completed + persistAndSubmit(listOf(completed), proofs) + _onchainPaymentResolution.value = PaykitOnchainPaymentProofResolution( + identity = proof.identity, + requestId = proof.requestId, + transactionId = txid.lowercase(), + ) + } + + fun consumeOnchainPaymentResolution(resolution: PaykitOnchainPaymentProofResolution) { + _onchainPaymentResolution.compareAndSet(resolution, null) + } + + fun clearOnchainPaymentResolution() { + _onchainPaymentResolution.value = null + } + + private suspend fun currentIdentity(): String? = paykitSdkService.identityStatus() + ?.publicKey + ?.let(PubkyPublicKeyFormat::normalized) + + private suspend fun submitReady(proof: PendingPaykitPaymentProof): Boolean { + val proofData = proof.proofData ?: return false val identityStatus = paykitSdkService.identityStatus() if ( identityStatus?.liveSessionAvailable != true || !PubkyPublicKeyFormat.matches(identityStatus.publicKey, proof.identity) ) { - return + return false } val record = paykitSdkService.paymentRequests().firstOrNull { it.paymentRequestId == proof.requestId.paymentRequestId && PubkyPublicKeyFormat.matches(it.counterparty, proof.requestId.counterparty) && it.counterpartyReceiverPath == proof.requestId.counterpartyReceiverPath - } ?: return + } ?: return false val proofJson = proofJson(proof.kind, proofData) val alreadyQueued = record.paymentProofs.any { - it.billingPeriod == null && + it.billingPeriod.matches(proof.billingPeriod) && it.paymentEndpointIdentifier == proof.paymentEndpointIdentifier && it.proof.exportText().proofValues() == proofJson.proofValues() } @@ -266,6 +460,7 @@ class PaykitPaymentProofRepo @Inject constructor( paymentRequestId = proof.requestId.paymentRequestId, paymentEndpointIdentifier = proof.paymentEndpointIdentifier, proofJson = proofJson, + billingPeriod = proof.billingPeriod, ) Logger.info("Queued a Paykit payment proof for private delivery", context = TAG) runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } @@ -280,6 +475,7 @@ class PaykitPaymentProofRepo @Inject constructor( removeProofsLocked { PubkyPublicKeyFormat.matches(it.identity, proof.identity) && it.requestId == proof.requestId } + return true } private suspend fun removeProofs(predicate: (PendingPaykitPaymentProof) -> Boolean) = withContext(ioDispatcher) { @@ -299,15 +495,31 @@ class PaykitPaymentProofRepo @Inject constructor( completedProofs: List, allProofs: List, ) { - runSuspendCatching { persist(allProofs) } + val didPersist = runSuspendCatching { persist(allProofs) } .onFailure { Logger.warn( "Failed to persist a completed Paykit payment proof; attempting immediate delivery", it, context = TAG, ) - } - completedProofs.forEach { submitReady(it) } + }.isSuccess + var hasUndeliveredProof = false + completedProofs.forEach { proof -> + val wasDelivered = runSuspendCatching { submitReady(proof) } + .onFailure { Logger.warn("Failed to queue a Paykit payment proof", it, context = TAG) } + .getOrDefault(false) + hasUndeliveredProof = hasUndeliveredProof || !wasDelivered + } + if (!didPersist && hasUndeliveredProof) { + runSuspendCatching { persist(allProofs) } + .onFailure { + Logger.warn( + "Failed to retain a completed Paykit payment proof for retry", + it, + context = TAG, + ) + } + } } private suspend fun pendingProof( @@ -331,6 +543,7 @@ class PaykitPaymentProofRepo @Inject constructor( requestId = request.id, paymentEndpointIdentifier = paymentEndpointIdentifier, kind = kind, + billingPeriod = request.billingPeriod, ) } @@ -341,6 +554,14 @@ class PaykitPaymentProofRepo @Inject constructor( } } +private fun com.synonym.paykit.BillingPeriod?.matches(period: PaykitBillingPeriod?): Boolean = when { + this == null && period == null -> true + this == null || period == null -> false + else -> runCatching { + kotlin.time.Instant.parse(startsAt) == period.startsAt && kotlin.time.Instant.parse(endsAt) == period.endsAt + }.getOrDefault(false) +} + private fun endpointSupports(identifier: String, kind: PaykitPaymentProofKind): Boolean { val method = MethodId.fromRawValue(identifier) ?: return false return when (kind) { @@ -349,6 +570,22 @@ private fun endpointSupports(identifier: String, kind: PaykitPaymentProofKind): } } +private fun PendingPaykitPaymentProof.isStartedFor( + identity: String, + requestId: PaykitPaymentRequestId, +): Boolean = PubkyPublicKeyFormat.matches(this.identity, identity) && + this.requestId == requestId && + (paymentStarted || paymentIdentifier != null || proofData != null) + +private fun PendingPaykitPaymentProof.isUnstartedFor( + identity: String, + requestId: PaykitPaymentRequestId, +): Boolean = PubkyPublicKeyFormat.matches(this.identity, identity) && + this.requestId == requestId && + !paymentStarted && + paymentIdentifier == null && + proofData == null + private fun proofJson(kind: PaykitPaymentProofKind, data: String): String = buildJsonObject { put("data", JsonPrimitive(data)) put("type", JsonPrimitive(kind.type)) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt index e03a7fe196..bc340883f3 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import to.bitkit.data.keychain.Keychain +import to.bitkit.models.PubkyPublicKeyFormat import javax.inject.Inject import javax.inject.Singleton @@ -22,6 +23,16 @@ class PaykitPaymentProofStore @Inject constructor( return Json.decodeFromString(value).proofs } + fun completedRequestProofKindsAwaitingSubmission( + identity: String, + ): Map = load() + .filter { PubkyPublicKeyFormat.matches(it.identity, identity) && it.proofData != null } + .associate { it.requestId to it.kind } + + fun inFlightRequestIds(identity: String): Set = load() + .filter { PubkyPublicKeyFormat.matches(it.identity, identity) && it.paymentStarted } + .mapTo(mutableSetOf()) { it.requestId } + suspend fun save(proofs: List) { keychain.upsertString( Keychain.Key.PAYKIT_PENDING_PAYMENT_PROOFS.name, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt index bb28ee0507..25574ccc27 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt @@ -1,3 +1,5 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + package to.bitkit.repositories import kotlinx.coroutines.sync.Mutex @@ -11,6 +13,12 @@ import to.bitkit.models.PubkyPublicKeyFormat import javax.inject.Inject import javax.inject.Singleton +data class PaykitSubscriptionPresentationState( + val acceptedAt: Map = emptyMap(), + val presentedProposalIds: Set = emptySet(), + val dismissedPaymentIds: Set = emptySet(), +) + @Singleton class PaykitPaymentRequestPresentationStore @Inject constructor( private val keychain: Keychain, @@ -20,6 +28,20 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( @Serializable private data class State( val idsByIdentity: Map> = emptyMap(), + val subscriptionStatesByIdentity: Map = emptyMap(), + ) + + @Serializable + private data class SubscriptionState( + val acceptances: List = emptyList(), + val presentedProposalIds: List = emptyList(), + val dismissedPaymentIds: List = emptyList(), + ) + + @Serializable + private data class SubscriptionAcceptance( + val id: PaykitSubscriptionId, + val acceptedAt: String, ) fun load(identity: String): Set { @@ -38,4 +60,46 @@ class PaykitPaymentRequestPresentationStore @Inject constructor( keychain.upsertString(Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name, Json.encodeToString(state)) } } + + fun loadSubscriptionState(identity: String): PaykitSubscriptionPresentationState { + val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) + ?: return PaykitSubscriptionPresentationState() + val value = keychain.loadString(Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name) + ?: return PaykitSubscriptionPresentationState() + val state = Json.decodeFromString(value).subscriptionStatesByIdentity[normalizedIdentity] + ?: return PaykitSubscriptionPresentationState() + val acceptedAt = state.acceptances.mapNotNull { acceptance -> + runCatching { kotlin.time.Instant.parse(acceptance.acceptedAt) } + .getOrNull() + ?.let { acceptance.id to it } + } + .toMap() + return PaykitSubscriptionPresentationState( + acceptedAt = acceptedAt, + presentedProposalIds = state.presentedProposalIds.toSet(), + dismissedPaymentIds = state.dismissedPaymentIds.toSet(), + ) + } + + suspend fun saveSubscriptionState( + identity: String, + subscriptionState: PaykitSubscriptionPresentationState, + ) { + mutex.withLock { + val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return@withLock + val current = keychain.loadString(Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name) + ?.let { Json.decodeFromString(it) } + ?: State() + val storedState = SubscriptionState( + acceptances = subscriptionState.acceptedAt.map { SubscriptionAcceptance(it.key, it.value.toString()) }, + presentedProposalIds = subscriptionState.presentedProposalIds.toList(), + dismissedPaymentIds = subscriptionState.dismissedPaymentIds.toList(), + ) + val state = current.copy( + subscriptionStatesByIdentity = current.subscriptionStatesByIdentity + + (normalizedIdentity to storedState), + ) + keychain.upsertString(Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name, Json.encodeToString(state)) + } + } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index df6e15cee8..42cda4c229 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -57,7 +57,14 @@ data class PaykitPaymentRequestId( val paymentRequestId: String, val counterparty: String, val counterpartyReceiverPath: String, -) + val billingPeriodStartsAt: String? = null, +) { + fun belongsTo(subscription: PaykitSubscription): Boolean = + billingPeriodStartsAt != null && + paymentRequestId == subscription.paymentRequestId && + counterparty == subscription.counterparty && + counterpartyReceiverPath == subscription.counterpartyReceiverPath +} data class PaykitPaymentRequest( val paymentRequestId: String, @@ -72,11 +79,22 @@ data class PaykitPaymentRequest( val deliveryStatus: PaykitPaymentRequestDeliveryStatus? = null, val direction: PaykitPaymentRequestDirection = PaykitPaymentRequestDirection.Incoming, val lifecycleState: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, + val billingPeriod: PaykitBillingPeriod? = null, + val paymentProofKind: PaykitPaymentProofKind? = null, ) { val id: PaykitPaymentRequestId - get() = PaykitPaymentRequestId(paymentRequestId, counterparty, counterpartyReceiverPath) + get() = PaykitPaymentRequestId( + paymentRequestId, + counterparty, + counterpartyReceiverPath, + billingPeriod?.startsAt?.toString(), + ) - fun isExpired(now: Instant): Boolean = expiresAt?.let { it <= now } == true + val requiresAcceptance: Boolean + get() = billingPeriod == null && lifecycleState == PaymentRequestLifecycleState.PROPOSED + + fun isExpired(now: Instant): Boolean = + lifecycleState == PaymentRequestLifecycleState.PROPOSED && expiresAt?.let { it <= now } == true fun acceptsLightningInvoiceAmountMsats(amountMsats: ULong?): Boolean = amountMsats == null || amountMsats == satsToMsat(amountSats) @@ -85,6 +103,12 @@ data class PaykitPaymentRequest( amountSats == 0uL || acceptsPaymentAmount(amountSats) fun acceptsPaymentAmount(amountSats: ULong): Boolean = amountSats == this.amountSats + + fun belongsTo(subscription: PaykitSubscription): Boolean = + billingPeriod != null && + paymentRequestId == subscription.paymentRequestId && + counterparty == subscription.counterparty && + counterpartyReceiverPath == subscription.counterpartyReceiverPath } enum class PaykitPaymentRequestDeliveryStatus { Queued, Sent } @@ -114,13 +138,16 @@ sealed class PaykitPaymentRequestError(message: String) : AppError(message) { data object OperationInProgress : PaykitPaymentRequestError("Payment request operation is already in progress") } -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList", "LargeClass") @Singleton class PaykitPaymentRequestRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val paykitSdkService: PaykitSdkService, private val settingsStore: SettingsStore, private val presentationStore: PaykitPaymentRequestPresentationStore, + private val paymentProofStore: PaykitPaymentProofStore, + private val paymentProofRepo: PaykitPaymentProofRepo, + private val subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler, private val clock: Clock, ) { companion object { @@ -138,6 +165,8 @@ class PaykitPaymentRequestRepo @Inject constructor( val pendingRequests: StateFlow> = _pendingRequests.asStateFlow() private val _paymentRequestHistory = MutableStateFlow>(emptyList()) val paymentRequestHistory: StateFlow> = _paymentRequestHistory.asStateFlow() + private val _subscriptions = MutableStateFlow>(emptyList()) + val subscriptions: StateFlow> = _subscriptions.asStateFlow() private val _eligibleTargets = MutableStateFlow>(emptyList()) val eligibleTargets: StateFlow> = _eligibleTargets.asStateFlow() private val _isCreatingRequest = MutableStateFlow(false) @@ -149,6 +178,18 @@ class PaykitPaymentRequestRepo @Inject constructor( @Volatile private var presentedRequestIds = emptySet() + @Volatile + private var subscriptionAcceptedAt = emptyMap() + + @Volatile + private var presentedSubscriptionProposalIds = emptySet() + + @Volatile + private var dismissedSubscriptionPaymentIds = emptySet() + + @Volatile + private var savedContactPublicKeys = emptyList() + suspend fun activate(identity: String) = withContext(ioDispatcher) { val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return@withContext if (!PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) { @@ -161,15 +202,38 @@ class PaykitPaymentRequestRepo @Inject constructor( presentedRequestIds = runSuspendCatching { presentationStore.load(normalizedIdentity) } .onFailure { Logger.warn("Failed to restore surfaced Paykit payment requests", it, context = TAG) } .getOrDefault(emptySet()) + val subscriptionState = runSuspendCatching { presentationStore.loadSubscriptionState(normalizedIdentity) } + .onFailure { Logger.warn("Failed to restore Paykit subscription state", it, context = TAG) } + .getOrDefault(PaykitSubscriptionPresentationState()) + subscriptionAcceptedAt = subscriptionState.acceptedAt + presentedSubscriptionProposalIds = subscriptionState.presentedProposalIds + dismissedSubscriptionPaymentIds = subscriptionState.dismissedPaymentIds } } fun automaticPendingRequests(): List = _pendingRequests.value.filterNot { it.id in presentedRequestIds } + fun subscriptionProposals(): List = + _subscriptions.value.filter { it.isProposalVisible(clock.now()) } + + fun automaticSubscriptionProposals(): List = + subscriptionProposals().filterNot { it.id in presentedSubscriptionProposalIds } + fun pendingRequest(id: PaykitPaymentRequestId): PaykitPaymentRequest? = _pendingRequests.value.firstOrNull { it.id == id } + fun synchronizeSubscriptionNotifications(enabled: Boolean) { + val identity = activeIdentity ?: return + subscriptionNotificationScheduler.synchronize( + subscriptions = _subscriptions.value, + acceptedAt = { subscriptionAcceptedAt[it.id] }, + pendingRequestIds = _pendingRequests.value.mapTo(mutableSetOf()) { it.id }, + payerIdentity = identity, + notificationsEnabled = enabled, + ) + } + suspend fun markPresented(request: PaykitPaymentRequest): Boolean = withContext(ioDispatcher) { operationMutex.withLock { if (_pendingRequests.value.none { it.id == request.id }) return@withLock false @@ -181,12 +245,53 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + suspend fun markSubscriptionProposalPresented( + subscription: PaykitSubscription, + ): Boolean = withContext(ioDispatcher) { + operationMutex.withLock { + val current = _subscriptions.value.firstOrNull { it.id == subscription.id } + ?.takeIf { it.isProposalVisible(clock.now()) } + ?: return@withLock false + if (current.id in presentedSubscriptionProposalIds) return@withLock true + val identity = activeIdentity ?: return@withLock false + presentedSubscriptionProposalIds = presentedSubscriptionProposalIds + current.id + persistSubscriptionState(identity) + true + } + } + + suspend fun dismissSubscriptionPayment(request: PaykitPaymentRequest): Boolean = withContext(ioDispatcher) { + operationMutex.withLock { + if (request.billingPeriod == null || _pendingRequests.value.none { it.id == request.id }) { + return@withLock false + } + val identity = activeIdentity ?: return@withLock false + dismissedSubscriptionPaymentIds = dismissedSubscriptionPaymentIds + request.id + _pendingRequests.update { requests -> requests.filterNot { it.id == request.id } } + presentedRequestIds = presentedRequestIds - request.id + runSuspendCatching { + presentationStore.saveSubscriptionState(identity, currentSubscriptionState()) + presentationStore.save(identity, presentedRequestIds) + }.onFailure { Logger.warn("Failed to persist dismissed Paykit subscription payment", it, context = TAG) } + subscriptionNotificationScheduler.synchronize( + subscriptions = _subscriptions.value, + acceptedAt = { subscriptionAcceptedAt[it.id] }, + pendingRequestIds = _pendingRequests.value.mapTo(mutableSetOf()) { it.id }, + payerIdentity = identity, + notificationsEnabled = settingsStore.data.first().notificationsGranted, + ) + scheduleExpirationLocked() + true + } + } + suspend fun refresh(savedPublicKeys: List = emptyList()): Result { val generation = stateGeneration.get() val expectedIdentity = activeIdentity return withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { + savedContactPublicKeys = savedPublicKeys if (!isAvailable()) { clearStateLocked() return@withLock @@ -273,17 +378,30 @@ class PaykitPaymentRequestRepo @Inject constructor( return PaykitPaymentRequestCreation(request, creatorIdentity, wasPublishedToActiveState) } - suspend fun accept(request: PaykitPaymentRequest): Result = updateRequest( - request = request, - resultingState = PaymentRequestLifecycleState.ACCEPTED, - ) { - paykitSdkService.acceptPaymentRequest( - counterparty = it.counterparty, - counterpartyReceiverPath = it.counterpartyReceiverPath, - paymentRequestId = it.paymentRequestId, - ) - }.onFailure { - Logger.warn("Failed to accept incoming Paykit payment request", it, context = TAG) + suspend fun accept(request: PaykitPaymentRequest): Result { + if (!request.requiresAcceptance) { + return withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + if (_pendingRequests.value.none { it.id == request.id }) { + throw PaykitPaymentRequestError.RequestUnavailable + } + } + } + } + } + return updateRequest( + request = request, + resultingState = PaymentRequestLifecycleState.ACCEPTED, + ) { + paykitSdkService.acceptPaymentRequest( + counterparty = it.counterparty, + counterpartyReceiverPath = it.counterpartyReceiverPath, + paymentRequestId = it.paymentRequestId, + ) + }.onFailure { + Logger.warn("Failed to accept incoming Paykit payment request", it, context = TAG) + } } suspend fun reject(request: PaykitPaymentRequest): Result = updateRequest( @@ -299,6 +417,66 @@ class PaykitPaymentRequestRepo @Inject constructor( Logger.warn("Failed to reject incoming Paykit payment request", it, context = TAG) } + suspend fun dismiss(request: PaykitPaymentRequest): Result { + if (request.billingPeriod != null) { + return runSuspendCatching { + if (!dismissSubscriptionPayment(request)) throw PaykitPaymentRequestError.RequestUnavailable + } + } + if (request.requiresAcceptance) return reject(request) + if (request.lifecycleState != PaymentRequestLifecycleState.ACCEPTED) { + return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + } + return updateRequest(request, PaymentRequestLifecycleState.CANCELED) { + paykitSdkService.cancelPaymentRequest( + counterparty = it.counterparty, + counterpartyReceiverPath = it.counterpartyReceiverPath, + paymentRequestId = it.paymentRequestId, + ) + } + } + + fun acceptedAt(subscription: PaykitSubscription): Instant? = subscriptionAcceptedAt[subscription.id] + + suspend fun accept(subscription: PaykitSubscription): Result = withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val validationDate = clock.now() + val current = _subscriptions.value.firstOrNull { it.id == subscription.id } + ?.takeIf { it == subscription && it.isProposalActionable(validationDate) } + ?: throw PaykitPaymentRequestError.RequestUnavailable + val record = paykitSdkService.acceptPaymentRequest( + current.counterparty, + current.counterpartyReceiverPath, + current.paymentRequestId, + ) + processPendingMessages() + val acceptanceDate = clock.now() + subscriptionAcceptedAt = subscriptionAcceptedAt + (current.id to acceptanceDate) + persistSubscriptionState(identity) + applySubscriptionRecordLocked(record, acceptanceDate) + synchronizeAfterSubscriptionAction(identity) + _pendingRequests.value + .filter { it.belongsTo(current) } + .minByOrNull { it.billingPeriod?.startsAt ?: Instant.DISTANT_FUTURE } + } + }.onFailure { Logger.warn("Failed to accept Paykit subscription", it, context = TAG) } + } + + suspend fun cancel(subscription: PaykitSubscription): Result = updateSubscription(subscription) { + if (!it.isActive(clock.now())) throw PaykitPaymentRequestError.RequestUnavailable + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val protectedRequestIds = paymentProofRepo.protectedRequestIdsForSubscriptionCancellation( + identity = identity, + subscriptionId = it.id, + ).getOrThrow() + if (protectedRequestIds.isNotEmpty()) { + throw PaykitPaymentRequestError.OperationInProgress + } + paykitSdkService.cancelPaymentRequest(it.counterparty, it.counterpartyReceiverPath, it.paymentRequestId) + } + fun isPending(request: PaykitPaymentRequest): Boolean = !request.isExpired(clock.now()) && _pendingRequests.value.any { it.id == request.id } @@ -315,10 +493,14 @@ class PaykitPaymentRequestRepo @Inject constructor( clearStateLocked() activeIdentity = null presentedRequestIds = emptySet() + presentedSubscriptionProposalIds = emptySet() + dismissedSubscriptionPaymentIds = emptySet() + savedContactPublicKeys = emptyList() } } } + @Suppress("LongMethod", "CyclomaticComplexMethod") private suspend fun synchronizeLocked( generation: Long, savedPublicKeys: List, @@ -328,8 +510,69 @@ class PaykitPaymentRequestRepo @Inject constructor( paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() val records = paykitSdkService.paymentRequests() - val incoming = records.mapNotNull { it.toPaykitPaymentRequest(PaymentRequestLocalRole.PAYER, now) } - val history = records.mapNotNull { it.toPaykitPaymentRequestHistory(now) } + val locallyCompletedProofKinds = expectedIdentity + ?.let(paymentProofStore::completedRequestProofKindsAwaitingSubmission) + .orEmpty() + val locallyCompletedRequestIds = locallyCompletedProofKinds.keys + val locallyInFlightRequestIds = expectedIdentity + ?.let(paymentProofStore::inFlightRequestIds) + .orEmpty() + val subscriptions = records.mapNotNull(PaymentRequestRecord::toPaykitSubscription) + .map { it.withExpiredLifecycle(now) } + val restoredAcceptances = subscriptions + .filter { + it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING || it.paidPeriods.isNotEmpty() + } + .filterNot { it.id in subscriptionAcceptedAt } + .associate { subscription -> + val acceptedAt = subscription.paidPeriods.minOfOrNull { it.startsAt } + ?: subscription.createdAt + ?: now + subscription.id to acceptedAt + } + if (restoredAcceptances.isNotEmpty()) { + subscriptionAcceptedAt = subscriptionAcceptedAt + restoredAcceptances + expectedIdentity?.let { persistSubscriptionState(it) } + } + val recurringRequestsBySubscription = subscriptions.associateWith { requestsThroughAcceptance(it, now) } + val activeRecurringRequestIds = recurringRequestsBySubscription + .filterKeys { it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING } + .values + .flatten() + .mapTo(mutableSetOf()) { it.id } + pruneDismissedSubscriptionPaymentIds(activeRecurringRequestIds, expectedIdentity) + val dueRequests = recurringRequestsBySubscription + .filterKeys { it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING } + .values + .flatten() + .filter { + it.lifecycleState != PaymentRequestLifecycleState.PROOF_SUBMITTED && + it.id !in locallyCompletedRequestIds && + it.id !in locallyInFlightRequestIds && + it.id !in dismissedSubscriptionPaymentIds + } + val recurringHistory = recurringRequestsBySubscription.values.flatten().mapNotNull { request -> + when { + request.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED -> request + request.id in locallyCompletedRequestIds -> request.copy( + lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + paymentProofKind = locallyCompletedProofKinds[request.id], + ) + else -> null + } + } + val oneTimeIncoming = records.mapNotNull { + it.toPaykitPaymentRequest(PaymentRequestLocalRole.PAYER, now) + }.filter { it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds } + val incoming = (dueRequests + oneTimeIncoming).sortedBy { it.createdAt } + val oneTimeHistory = records.mapNotNull { it.toPaykitPaymentRequestHistory(now) }.map { request -> + val proofKind = locallyCompletedProofKinds[request.id] ?: return@map request + request.copy( + lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + paymentProofKind = proofKind, + ) + } + val history = (recurringHistory + oneTimeHistory) .sortedByDescending { it.createdAt } val targets = expectedIdentity?.let { eligibleTargets(savedPublicKeys, it) }.orEmpty() if ( @@ -340,11 +583,28 @@ class PaykitPaymentRequestRepo @Inject constructor( } _pendingRequests.update { incoming } _paymentRequestHistory.update { history } + _subscriptions.update { subscriptions } + prunePresentedSubscriptionProposalIds(subscriptions) + subscriptionNotificationScheduler.synchronize( + subscriptions = subscriptions, + acceptedAt = { subscriptionAcceptedAt[it.id] }, + pendingRequestIds = incoming.mapTo(mutableSetOf()) { it.id }, + payerIdentity = expectedIdentity ?: return, + notificationsEnabled = settingsStore.data.first().notificationsGranted, + ) _eligibleTargets.update { targets } prunePresentedRequestIds(incoming) scheduleExpirationLocked() } + private fun requestsThroughAcceptance( + subscription: PaykitSubscription, + now: Instant, + ): List { + val acceptedAt = subscriptionAcceptedAt[subscription.id] ?: return emptyList() + return subscription.requestsThrough(now, acceptedAt) + } + private fun isCurrentState(generation: Long, expectedIdentity: String?): Boolean = stateGeneration.get() == generation && PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity) @@ -435,6 +695,69 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + private suspend fun updateSubscription( + subscription: PaykitSubscription, + operation: suspend (PaykitSubscription) -> PaymentRequestRecord, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + val current = _subscriptions.value.firstOrNull { it.id == subscription.id } + ?: throw PaykitPaymentRequestError.RequestUnavailable + val record = operation(current) + processPendingMessages() + val identity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + applySubscriptionRecordLocked(record, clock.now()) + synchronizeAfterSubscriptionAction(identity) + } + }.onFailure { Logger.warn("Failed to update Paykit subscription", it, context = TAG) } + } + + private suspend fun synchronizeAfterSubscriptionAction(identity: String) { + runSuspendCatching { synchronizeLocked(stateGeneration.get(), savedContactPublicKeys, identity) } + .onFailure { + Logger.warn( + "Failed to refresh Paykit subscriptions after a committed action", + it, + context = TAG, + ) + } + } + + private suspend fun applySubscriptionRecordLocked(record: PaymentRequestRecord, now: Instant) { + val subscription = record.toPaykitSubscription()?.withExpiredLifecycle(now) ?: return + _subscriptions.update { subscriptions -> + subscriptions.filterNot { it.id == subscription.id } + subscription + } + + val recurringRequests = requestsThroughAcceptance(subscription, now) + val unpaidRequests = if (subscription.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING) { + recurringRequests.filter { it.lifecycleState != PaymentRequestLifecycleState.PROOF_SUBMITTED } + } else { + emptyList() + } + val paidRequests = recurringRequests.filter { + it.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED + } + val existingPending = _pendingRequests.value.filterNot { it.belongsTo(subscription) } + _pendingRequests.update { + (unpaidRequests + existingPending).sortedBy { it.createdAt } + } + val existingHistory = _paymentRequestHistory.value.filterNot { it.belongsTo(subscription) } + _paymentRequestHistory.update { + (paidRequests + existingHistory).sortedByDescending { it.createdAt } + } + prunePresentedSubscriptionProposalIds(_subscriptions.value) + subscriptionNotificationScheduler.synchronize( + subscriptions = _subscriptions.value, + acceptedAt = { subscriptionAcceptedAt[it.id] }, + pendingRequestIds = _pendingRequests.value.mapTo(mutableSetOf()) { it.id }, + payerIdentity = activeIdentity ?: return, + notificationsEnabled = settingsStore.data.first().notificationsGranted, + ) + prunePresentedRequestIds(_pendingRequests.value) + scheduleExpirationLocked() + } + private suspend fun processPendingMessages(): List = runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } .onSuccess(::logOutboundFailures) @@ -475,7 +798,9 @@ class PaykitPaymentRequestRepo @Inject constructor( val now = clock.now() _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } _paymentRequestHistory.update { requests -> requests.withExpiredLifecycle(now) } + _subscriptions.update { subscriptions -> subscriptions.map { it.withExpiredLifecycle(now) } } prunePresentedRequestIds(_pendingRequests.value) + prunePresentedSubscriptionProposalIds(_subscriptions.value) scheduleExpirationLocked() } @@ -489,23 +814,65 @@ class PaykitPaymentRequestRepo @Inject constructor( .onFailure { Logger.warn("Failed to persist surfaced Paykit payment requests", it, context = TAG) } } + private suspend fun prunePresentedSubscriptionProposalIds(subscriptions: List) { + val proposalIds = subscriptions + .filter { it.isProposalVisible(clock.now()) } + .mapTo(mutableSetOf()) { it.id } + val prunedIds = presentedSubscriptionProposalIds.intersect(proposalIds) + if (prunedIds == presentedSubscriptionProposalIds) return + presentedSubscriptionProposalIds = prunedIds + val identity = activeIdentity ?: return + persistSubscriptionState(identity) + } + + private suspend fun pruneDismissedSubscriptionPaymentIds( + activeRequestIds: Set, + identity: String?, + ) { + val prunedIds = dismissedSubscriptionPaymentIds.intersect(activeRequestIds) + if (prunedIds == dismissedSubscriptionPaymentIds) return + dismissedSubscriptionPaymentIds = prunedIds + identity ?: return + persistSubscriptionState(identity) + } + + private fun currentSubscriptionState() = PaykitSubscriptionPresentationState( + acceptedAt = subscriptionAcceptedAt, + presentedProposalIds = presentedSubscriptionProposalIds, + dismissedPaymentIds = dismissedSubscriptionPaymentIds, + ) + + private suspend fun persistSubscriptionState(identity: String) { + runSuspendCatching { presentationStore.saveSubscriptionState(identity, currentSubscriptionState()) } + .onFailure { Logger.warn("Failed to persist Paykit subscription state", it, context = TAG) } + } + private fun clearStateLocked() { expirationJob?.cancel() expirationJob = null _pendingRequests.update { emptyList() } _paymentRequestHistory.update { emptyList() } + _subscriptions.update { emptyList() } _eligibleTargets.update { emptyList() } + savedContactPublicKeys = emptyList() + subscriptionNotificationScheduler.cancel() } private fun scheduleExpirationLocked() { expirationJob?.cancel() expirationJob = null - val nextExpiration = (_pendingRequests.value + _paymentRequestHistory.value) - .asSequence() + val requestExpirations = (_pendingRequests.value + _paymentRequestHistory.value) .filter { it.lifecycleState == PaymentRequestLifecycleState.PROPOSED } .mapNotNull { it.expiresAt } - .minOrNull() + val subscriptionExpirations = _subscriptions.value + .filter { + it.lifecycleState == PaymentRequestLifecycleState.PROPOSED || + it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING + } + .flatMap { listOfNotNull(it.proposalExpiresAt, it.recurrence.endsAt) } + .filter { it > clock.now() } + val nextExpiration = (requestExpirations + subscriptionExpirations).minOrNull() ?: return val delayDuration = (nextExpiration - clock.now()).coerceAtLeast(Duration.ZERO) expirationJob = repoScope.launch { @@ -528,17 +895,23 @@ private fun List.withExpiredLifecycle(now: Instant): List< private val bitcoinAmountPattern = Regex("(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)") -@Suppress("CyclomaticComplexMethod", "ReturnCount") +@Suppress("CyclomaticComplexMethod", "ReturnCount", "LongMethod") private fun PaymentRequestRecord.toPaykitPaymentRequest( expectedRole: PaymentRequestLocalRole, now: Instant, requiresActionableRequest: Boolean = true, ): PaykitPaymentRequest? { if (localRole != expectedRole || state == PaymentRequestLifecycleState.ACTIVE_RECURRING) return null - if (requiresActionableRequest && state != PaymentRequestLifecycleState.PROPOSED) return null + if ( + requiresActionableRequest && + state != PaymentRequestLifecycleState.PROPOSED && + state != PaymentRequestLifecycleState.ACCEPTED + ) { + return null + } val requestTerms = terms ?: return null if (requestTerms.recurrence != null || requestTerms.amount.asset != "btc") return null - val amountSats = requestTerms.amount.value.toSats() + val amountSats = requestTerms.amount.value.toPaykitSats() ?.takeIf { it <= ULong.MAX_VALUE / 1000uL } ?: return null val endpoints = requestTerms.acceptedPaymentEndpointIdentifiers @@ -549,7 +922,10 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( val expiresAt = requestTerms.proposalExpiresAt?.let { runCatching { Instant.parse(it) }.getOrNull() ?: return null } - if (requiresActionableRequest && expiresAt != null && expiresAt <= now) return null + val isExpiredProposal = state == PaymentRequestLifecycleState.PROPOSED && expiresAt?.let { it <= now } == true + if (requiresActionableRequest && isExpiredProposal) { + return null + } return PaykitPaymentRequest( paymentRequestId = paymentRequestId, @@ -580,6 +956,9 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( } else { state }, + paymentProofKind = paymentProofs.lastOrNull()?.let { + PaykitPaymentProofKind.fromPaymentEndpointIdentifier(it.paymentEndpointIdentifier) + }, ) } @@ -623,7 +1002,7 @@ private fun PaymentRequestRecord.toCreatedPaykitPaymentRequest( ) } -private fun PrivateJsonObject.note(): String? = runCatching { +internal fun PrivateJsonObject.note(): String? = runCatching { Json.parseToJsonElement(exportText()) .jsonObject["note"] ?.jsonPrimitive @@ -635,7 +1014,7 @@ private fun PrivateJsonObject.note(): String? = runCatching { private fun ULong.toBitcoinAmount(): String = BigDecimal(toString()).movePointLeft(8).stripTrailingZeros().toPlainString() -private fun String.toSats(): ULong? { +internal fun String.toPaykitSats(): ULong? { if (!bitcoinAmountPattern.matches(this)) return null return runCatching { BigDecimal(this).movePointRight(8).toBigIntegerExact().toString().toULong() diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt new file mode 100644 index 0000000000..12edeb2ec3 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -0,0 +1,311 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.time.ZoneOffset +import java.time.ZonedDateTime +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Serializable +data class PaykitBillingPeriod( + val startsAt: Instant, + val endsAt: Instant, +) { + val sdkValue: BillingPeriod + get() = BillingPeriod(startsAt.toString(), endsAt.toString()) +} + +enum class PaykitRecurrenceUnit(val rawValue: String) { + Minute("minute"), + Hour("hour"), + Day("day"), + Week("week"), + Month("month"), + Year("year"); + + val isSupported: Boolean + get() = this !in setOf(Minute, Hour) + + companion object { + fun fromRawValue(value: String): PaykitRecurrenceUnit? = entries.firstOrNull { it.rawValue == value } + } +} + +data class PaykitSubscriptionRecurrence( + val every: Int, + val unit: PaykitRecurrenceUnit, + val startsAt: Instant, + val anchor: Instant, + val endsAt: Instant?, +) { + val canMaterializePeriods: Boolean + get() = firstBoundaryIndexAfter(startsAt) != null + + @Suppress("ReturnCount") + fun periodsThrough(date: Instant, acceptedAt: Instant): List { + if (!unit.isSupported || startsAt > date) return emptyList() + val periods = mutableListOf() + var start = startsAt + var index = firstBoundaryIndexAfter(start) ?: return emptyList() + repeat(MAX_PERIODS) { + if (start > date) return periods + var end = boundary(index++) ?: return periods + if (end <= start) end = addInterval(start) ?: return periods + endsAt?.let { + if (start >= it) return periods + if (end > it) end = it + } + if (end <= start) return periods + if (end > acceptedAt) periods += PaykitBillingPeriod(start, end) + start = end + } + return periods + } + + @Suppress("ReturnCount") + fun nextPeriodAfter(date: Instant): PaykitBillingPeriod? { + var start = startsAt + var index = firstBoundaryIndexAfter(start) ?: return null + repeat(MAX_PERIODS) { + var end = boundary(index++) ?: return null + if (end <= start) end = addInterval(start) ?: return null + endsAt?.let { + if (start >= it) return null + if (end > it) end = it + } + if (start > date) return PaykitBillingPeriod(start, end) + start = end + } + return null + } + + fun upcomingPeriodsAfter(date: Instant, limit: Int): List { + if (limit <= 0) return emptyList() + val periods = mutableListOf() + var cursor = date + repeat(limit.coerceAtMost(MAX_PERIODS)) { + val period = nextPeriodAfter(cursor) ?: return periods + periods += period + cursor = period.startsAt + } + return periods + } + + private fun firstBoundaryIndexAfter(date: Instant): Int? { + var index = 0 + val anchorBoundary = boundary(index) ?: return null + if (anchorBoundary > date) { + while (index > -MAX_PERIODS && boundary(index - 1)?.let { it > date } == true) index-- + } else { + while (index < MAX_PERIODS && boundary(index)?.let { it <= date } == true) index++ + } + + boundary(index)?.takeIf { it > date } ?: return null + boundary(index - 1)?.takeIf { it <= date } ?: return null + return index + } + + private fun boundary(index: Int): Instant? = runCatching { + val value = every.toLong() * index + when (unit) { + PaykitRecurrenceUnit.Minute -> anchor.utc().plusMinutes(value) + PaykitRecurrenceUnit.Hour -> anchor.utc().plusHours(value) + PaykitRecurrenceUnit.Day -> anchor.utc().plusDays(value) + PaykitRecurrenceUnit.Week -> anchor.utc().plusWeeks(value) + PaykitRecurrenceUnit.Month -> anchor.utc().plusMonths(value) + PaykitRecurrenceUnit.Year -> anchor.utc().plusYears(value) + }.toKotlinInstant() + }.getOrNull() + + private fun addInterval(date: Instant): Instant? = runCatching { + val value = every.toLong() + when (unit) { + PaykitRecurrenceUnit.Minute -> date.utc().plusMinutes(value) + PaykitRecurrenceUnit.Hour -> date.utc().plusHours(value) + PaykitRecurrenceUnit.Day -> date.utc().plusDays(value) + PaykitRecurrenceUnit.Week -> date.utc().plusWeeks(value) + PaykitRecurrenceUnit.Month -> date.utc().plusMonths(value) + PaykitRecurrenceUnit.Year -> date.utc().plusYears(value) + }.toKotlinInstant() + }.getOrNull() + + private companion object { + const val MAX_PERIODS = 10_000 + } +} + +data class PaykitSubscriptionMetadata( + val description: String?, + val benefits: List, +) + +@Serializable +data class PaykitSubscriptionId( + val paymentRequestId: String, + val counterparty: String, + val counterpartyReceiverPath: String, +) + +data class PaykitSubscription( + val paymentRequestId: String, + val counterparty: String, + val counterpartyReceiverPath: String, + val amountValue: String, + val amountSats: ULong, + val note: String?, + val createdAt: Instant?, + val proposalExpiresAt: Instant?, + val recurrence: PaykitSubscriptionRecurrence, + val metadata: PaykitSubscriptionMetadata, + val acceptedPaymentEndpointIdentifiers: List, + val lifecycleState: PaymentRequestLifecycleState, + val paidPeriods: List, + val paymentProofKinds: Map = emptyMap(), +) { + val id: PaykitSubscriptionId + get() = PaykitSubscriptionId(paymentRequestId, counterparty, counterpartyReceiverPath) + + fun isProposalVisible(now: Instant): Boolean = + lifecycleState == PaymentRequestLifecycleState.PROPOSED && + proposalExpiresAt?.let { it > now } != false && + recurrence.endsAt?.let { it > now } != false + + fun isProposalActionable(now: Instant): Boolean = + isProposalVisible(now) && + recurrence.unit.isSupported && + recurrence.canMaterializePeriods && + acceptedPaymentEndpointIdentifiers.isNotEmpty() + + fun isActive(now: Instant): Boolean = + lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && recurrence.endsAt?.let { it > now } != false + + fun isExpired(now: Instant): Boolean = lifecycleState in setOf( + PaymentRequestLifecycleState.CANCELED, + PaymentRequestLifecycleState.REJECTED, + PaymentRequestLifecycleState.PROPOSAL_EXPIRED, + ) || (lifecycleState == PaymentRequestLifecycleState.PROPOSED && proposalExpiresAt?.let { it <= now } == true) || + recurrence.endsAt?.let { it <= now } == true + + fun withExpiredLifecycle(now: Instant): PaykitSubscription = when { + lifecycleState != PaymentRequestLifecycleState.PROPOSED -> this + proposalExpiresAt?.let { it <= now } == true || recurrence.endsAt?.let { it <= now } == true -> { + copy(lifecycleState = PaymentRequestLifecycleState.PROPOSAL_EXPIRED) + } + else -> this + } + + fun requestsThrough(date: Instant, acceptedAt: Instant): List = + recurrence.periodsThrough(date, acceptedAt).map { period -> + PaykitPaymentRequest( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + amountValue = amountValue, + amountSats = amountSats, + note = note, + createdAt = period.startsAt, + expiresAt = null, + acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers, + lifecycleState = if (period in paidPeriods) { + PaymentRequestLifecycleState.PROOF_SUBMITTED + } else { + PaymentRequestLifecycleState.ACTIVE_RECURRING + }, + billingPeriod = period, + paymentProofKind = paymentProofKinds[period], + ) + } + + fun paymentDueOnAcceptance(now: Instant): PaykitPaymentRequest? = requestsThrough(now, now).firstOrNull() +} + +@Suppress("CyclomaticComplexMethod", "ReturnCount") +internal fun PaymentRequestRecord.toPaykitSubscription(): PaykitSubscription? { + if (localRole != PaymentRequestLocalRole.PAYER) return null + val requestTerms = terms ?: return null + val sdkRecurrence = requestTerms.recurrence ?: return null + if ( + requestTerms.amount.asset != "btc" || + sdkRecurrence.every == 0u || + sdkRecurrence.every > Int.MAX_VALUE.toUInt() + ) { + return null + } + val recurrenceUnit = PaykitRecurrenceUnit.fromRawValue(sdkRecurrence.unit) ?: return null + val startsAt = sdkRecurrence.startsAt.parseInstant() ?: return null + val anchor = sdkRecurrence.anchor.parseInstant() ?: return null + val recurrenceEndsAt = sdkRecurrence.endsAt?.parseInstant() + ?: if (sdkRecurrence.endsAt == null) null else return null + val proposalExpiresAt = requestTerms.proposalExpiresAt?.parseInstant() + ?: if (requestTerms.proposalExpiresAt == null) null else return null + if (recurrenceEndsAt != null && recurrenceEndsAt <= startsAt) return null + val amountSats = requestTerms.amount.value.toPaykitSats() + ?.takeIf { it <= ULong.MAX_VALUE / 1000uL } + ?: return null + val endpoints = requestTerms.acceptedPaymentEndpointIdentifiers + .filter { MethodId.fromRawValue(it) != null } + .distinct() + val metadataObject = requestTerms.metadata.subscriptionMetadata() + val payments = paymentProofs.mapNotNull { proof -> + val period = proof.billingPeriod ?: return@mapNotNull null + val periodStart = period.startsAt.parseInstant() ?: return@mapNotNull null + val periodEnd = period.endsAt.parseInstant() ?: return@mapNotNull null + val billingPeriod = PaykitBillingPeriod(periodStart, periodEnd).takeIf { periodStart < periodEnd } + ?: return@mapNotNull null + billingPeriod to PaykitPaymentProofKind.fromPaymentEndpointIdentifier(proof.paymentEndpointIdentifier) + } + return PaykitSubscription( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + amountValue = requestTerms.amount.value, + amountSats = amountSats, + note = requestTerms.metadata.note()?.take(256), + createdAt = lastEventAt?.parseInstant(), + proposalExpiresAt = proposalExpiresAt, + recurrence = PaykitSubscriptionRecurrence( + every = sdkRecurrence.every.toInt(), + unit = recurrenceUnit, + startsAt = startsAt, + anchor = anchor, + endsAt = recurrenceEndsAt, + ), + metadata = metadataObject, + acceptedPaymentEndpointIdentifiers = endpoints, + lifecycleState = state, + paidPeriods = payments.map { it.first }, + paymentProofKinds = payments.mapNotNull { (period, kind) -> kind?.let { period to it } }.toMap(), + ) +} + +private fun com.synonym.paykit.PrivateJsonObject.subscriptionMetadata(): PaykitSubscriptionMetadata = runCatching { + val subscription = Json.parseToJsonElement(exportText()).jsonObject["subscription"]?.jsonObject + ?: return@runCatching PaykitSubscriptionMetadata(null, emptyList()) + if (subscription["version"]?.jsonPrimitive?.contentOrNull != "1") { + return@runCatching PaykitSubscriptionMetadata(null, emptyList()) + } + val description = subscription["description"]?.jsonPrimitive?.contentOrNull?.clean(1024) + val benefits = subscription["benefits"]?.jsonArray.orEmpty() + .take(8) + .mapNotNull { it.jsonPrimitive.contentOrNull?.clean(160) } + PaykitSubscriptionMetadata(description, benefits) +}.getOrDefault(PaykitSubscriptionMetadata(null, emptyList())) + +private fun String.clean(limit: Int): String? = trim().take(limit).takeIf(String::isNotEmpty) + +private fun String.parseInstant(): Instant? = runCatching { Instant.parse(this) }.getOrNull() + +private fun Instant.utc(): ZonedDateTime = java.time.Instant.parse(toString()).atZone(ZoneOffset.UTC) + +private fun ZonedDateTime.toKotlinInstant(): Instant = Instant.parse(toInstant().toString()) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt new file mode 100644 index 0000000000..16a1f11dcb --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt @@ -0,0 +1,155 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package to.bitkit.repositories + +import android.content.Context +import android.os.Bundle +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext +import to.bitkit.App +import to.bitkit.R +import to.bitkit.ui.EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT +import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY +import to.bitkit.ui.EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH +import to.bitkit.ui.EXTRA_PAYKIT_PAYER_IDENTITY +import to.bitkit.ui.EXTRA_PAYKIT_PAYMENT_REQUEST_ID +import to.bitkit.ui.EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE +import to.bitkit.ui.pushNotification +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Singleton +class PaykitSubscriptionNotificationScheduler @Inject constructor( + @ApplicationContext private val context: Context, + private val clock: Clock, +) { + private companion object { + const val MAX_NOTIFICATIONS = 32 + const val PREFERENCES_NAME = "paykit-subscription-notifications" + const val SCHEDULED_WORK_NAMES_KEY = "scheduled-work-names" + const val WORK_PREFIX = "paykit-subscription-" + const val WORK_TAG = "paykit-subscriptions" + } + + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private var scheduledWorkNames = preferences.getStringSet(SCHEDULED_WORK_NAMES_KEY, emptySet()).orEmpty() + private var notificationsWereEnabled: Boolean? = null + + @Synchronized + fun synchronize( + subscriptions: List, + acceptedAt: (PaykitSubscription) -> Instant?, + pendingRequestIds: Set, + payerIdentity: String, + notificationsEnabled: Boolean, + ) { + val workManager = WorkManager.getInstance(context) + if (!notificationsEnabled) { + if (notificationsWereEnabled != false) workManager.cancelAllWorkByTag(WORK_TAG) + updateScheduledWorkNames(emptySet()) + notificationsWereEnabled = false + return + } + + val now = clock.now() + val scheduledWork = subscriptions + .filter { + it.isActive(now) && + it.recurrence.unit.isSupported && + acceptedAt(it) != null + } + .flatMap { subscription -> + subscription.recurrence.upcomingPeriodsAfter(now, MAX_NOTIFICATIONS) + .map { subscription to it } + } + .sortedBy { it.second.startsAt } + .take(MAX_NOTIFICATIONS) + .associate { (subscription, period) -> + val workName = "$WORK_PREFIX$payerIdentity|${subscription.counterparty}|" + + "${subscription.counterpartyReceiverPath}|${subscription.paymentRequestId}|${period.startsAt}" + val delay = (period.startsAt - now).inWholeMilliseconds.coerceAtLeast(0) + val work = OneTimeWorkRequestBuilder() + .setInitialDelay(delay, TimeUnit.MILLISECONDS) + .setInputData( + workDataOf( + EXTRA_PAYKIT_PAYMENT_REQUEST_ID to subscription.paymentRequestId, + EXTRA_PAYKIT_PAYER_IDENTITY to payerIdentity, + EXTRA_PAYKIT_COUNTERPARTY to subscription.counterparty, + EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH to subscription.counterpartyReceiverPath, + EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT to period.startsAt.toString(), + ) + ) + .addTag(WORK_TAG) + .build() + workName to work + } + val pendingWorkNames = pendingRequestIds.mapNotNullTo(mutableSetOf()) { requestId -> + requestId.billingPeriodStartsAt?.let { + "$WORK_PREFIX$payerIdentity|${requestId.counterparty}|${requestId.counterpartyReceiverPath}|" + + "${requestId.paymentRequestId}|$it" + } + } + val desiredWorkNames = scheduledWork.keys + scheduledWorkNames.intersect(pendingWorkNames) + (scheduledWorkNames - desiredWorkNames).forEach(workManager::cancelUniqueWork) + scheduledWork + .filterKeys { it !in scheduledWorkNames } + .forEach { (workName, work) -> + workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, work) + } + updateScheduledWorkNames(desiredWorkNames) + notificationsWereEnabled = true + } + + @Synchronized + fun cancel() { + WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG) + updateScheduledWorkNames(emptySet()) + notificationsWereEnabled = false + } + + private fun updateScheduledWorkNames(workNames: Set) { + scheduledWorkNames = workNames + preferences.edit().putStringSet(SCHEDULED_WORK_NAMES_KEY, workNames).apply() + } +} + +@HiltWorker +class PaykitSubscriptionNotificationWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + if (App.currentActivity?.value != null) return Result.success() + applicationContext.pushNotification( + title = applicationContext.getString(R.string.subscriptions__payment_due_title), + text = applicationContext.getString(R.string.subscriptions__payment_due_description), + extras = Bundle().apply { + putBoolean(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE, true) + putString(EXTRA_PAYKIT_PAYER_IDENTITY, inputData.getString(EXTRA_PAYKIT_PAYER_IDENTITY)) + putString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID, inputData.getString(EXTRA_PAYKIT_PAYMENT_REQUEST_ID)) + putString(EXTRA_PAYKIT_COUNTERPARTY, inputData.getString(EXTRA_PAYKIT_COUNTERPARTY)) + putString( + EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH, + inputData.getString(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH), + ) + putString( + EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT, + inputData.getString(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT), + ) + }, + ) + return Result.success() + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index fc0ea04eef..e4e3fa6d58 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -89,7 +89,7 @@ class PrivatePaykitRepo @Inject constructor( 90.seconds, ) private val initialLinkBurstRetryDelays = List(14) { 2.seconds } - private val privatePaymentResolutionRetryDelays = privateMessageDrainRetryDelays.take(3) + private val privatePaymentResolutionRetryDelays = initialLinkBurstRetryDelays fun isDuplicatePaymentError(error: Throwable): Boolean = PrivatePaykitErrorClassifier.isDuplicatePaymentError(error) @@ -377,6 +377,18 @@ class PrivatePaykitRepo @Inject constructor( Logger.warn("Failed to present incoming Paykit payment request", it, context = TAG) } + suspend fun beginPaymentRequestWaitingForUpdatedList( + request: PaykitPaymentRequest, + ): Result = runSuspendCatching { + var result = beginPaymentRequest(request).getOrThrow() + for (retryDelay in privatePaymentResolutionRetryDelays) { + if (result != PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) return@runSuspendCatching result + delay(retryDelay) + result = beginPaymentRequest(request).getOrThrow() + } + result + } + suspend fun consumePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index fcf69c9e75..bc75271ed6 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -655,12 +655,14 @@ class PaykitSdkService @Inject constructor( } } + @Suppress("LongParameterList") suspend fun submitPaymentProof( counterparty: String, counterpartyReceiverPath: String, paymentRequestId: String, paymentEndpointIdentifier: String, proofJson: String, + billingPeriod: to.bitkit.repositories.PaykitBillingPeriod? = null, ): PaymentRequestRecord { isSetup.await() return operationMutex.withLock { @@ -670,7 +672,7 @@ class PaykitSdkService @Inject constructor( counterpartyReceiverPath, paymentRequestId, PaymentProofSubmission( - billingPeriod = null, + billingPeriod = billingPeriod?.sdkValue, paymentEndpointIdentifier = paymentEndpointIdentifier, proof = PrivateJsonObject(proofJson), ), @@ -693,6 +695,20 @@ class PaykitSdkService @Inject constructor( } } + suspend fun cancelPaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String, + reason: String? = null, + ): PaymentRequestRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.cancelPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId, reason) + } + } + } + suspend fun linkedPeers(): List { isSetup.await() return operationMutex.withLock { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index a8222c8085..8c5a748689 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -59,8 +59,11 @@ import to.bitkit.env.Env import to.bitkit.ext.rawId import to.bitkit.ext.walletId import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast import to.bitkit.repositories.ConnectivityState +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.ui.Routes.ExternalConnection import to.bitkit.ui.components.AuthCheckScreen import to.bitkit.ui.components.DefaultSheetContainerColor @@ -91,7 +94,7 @@ import to.bitkit.ui.screens.contacts.ContactsViewModel import to.bitkit.ui.screens.contacts.EditContactScreen import to.bitkit.ui.screens.contacts.EditContactViewModel import to.bitkit.ui.screens.contacts.shouldDiscardPendingImport -import to.bitkit.ui.screens.paymentrequests.PaymentRequestsScreen +import to.bitkit.ui.screens.paymentrequests.IncomingPaymentRequestDetailsScreen import to.bitkit.ui.screens.paymentrequests.PaymentRequestsSheet import to.bitkit.ui.screens.profile.CreateProfileScreen import to.bitkit.ui.screens.profile.CreateProfileViewModel @@ -116,6 +119,9 @@ import to.bitkit.ui.screens.settings.VssDebugScreen import to.bitkit.ui.screens.shop.ShopIntroScreen import to.bitkit.ui.screens.shop.shopDiscover.ShopDiscoverScreen import to.bitkit.ui.screens.shop.shopWebView.ShopWebViewScreen +import to.bitkit.ui.screens.subscriptions.SubscriptionDetailScreen +import to.bitkit.ui.screens.subscriptions.SubscriptionSheet +import to.bitkit.ui.screens.subscriptions.SubscriptionsScreen import to.bitkit.ui.screens.transfer.FundingAdvancedScreen import to.bitkit.ui.screens.transfer.FundingScreen import to.bitkit.ui.screens.transfer.LiquidityScreen @@ -311,6 +317,10 @@ fun ContentView( LaunchedEffect(Unit) { walletViewModel.handleHideBalanceOnOpen() } + LaunchedEffect(notificationsGranted) { + appViewModel.synchronizeSubscriptionNotifications(notificationsGranted) + } + val pendingScreenDeepLink by appViewModel.pendingScreenDeepLink.collectAsStateWithLifecycle() LaunchedEffect(pendingScreenDeepLink) { @@ -454,6 +464,9 @@ fun ContentView( val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() val isCreatingPaymentRequest by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + val isAcceptingSubscription by appViewModel.isAcceptingSubscription.collectAsStateWithLifecycle() + val isRetryingInitialSubscriptionPayment by + appViewModel.isRetryingInitialSubscriptionPayment.collectAsStateWithLifecycle() var homeWalletPageRequest by remember { mutableIntStateOf(0) } var homeWidgetsPageRequest by remember { mutableIntStateOf(0) } val navigateToHomeWallet = { @@ -479,7 +492,9 @@ fun ContentView( onDismiss = { appViewModel.hideSheet() }, visibilityKey = currentSheet, onVisible = { appViewModel.onSheetVisible(currentSheet) }, - dismissEnabled = !isCreatingPaymentRequest, + dismissEnabled = !isCreatingPaymentRequest && + !isAcceptingSubscription && + !isRetryingInitialSubscriptionPayment, sheetHandlePlacement = when (currentSheet) { is Sheet.Widgets -> SheetHandlePlacement.ContentOverlay else -> SheetHandlePlacement.ScaffoldSlot @@ -519,10 +534,16 @@ fun ContentView( onNotNow = appViewModel::hideSheet, onSeeAll = { appViewModel.hideSheet() - navController.navigateTo(Routes.PaymentRequests) + navController.navigateTo(Routes.Subscriptions(showPayments = true)) + }, + onDetails = { + appViewModel.hideSheet() + navController.navigateTo(it.toRoute()) }, ) + is Sheet.Subscription -> SubscriptionSheet(appViewModel, sheet.route) + is Sheet.ActivityDateRangeSelector -> DateRangeSelectorSheet() is Sheet.ActivityTagSelector -> TagSelectorSheet() is Sheet.Pin -> PinSheet(sheet, appViewModel) @@ -727,14 +748,50 @@ private fun RootNavHost( activityListViewModel = activityListViewModel, navController = navController, ) - composableWithDefaultTransitions { + composableWithDefaultTransitions { backStackEntry -> PaykitRouteGuard(settingsViewModel, navController) { - PaymentRequestsScreen( + val route = backStackEntry.toRoute() + SubscriptionsScreen( appViewModel = appViewModel, onBack = { navController.popBackStack() }, onRequestPayment = { - appViewModel.showSheet(Sheet.Receive(route = ReceiveRoute.PaymentRequestDetails)) + appViewModel.showSheet(Sheet.Receive(route = ReceiveRoute.PaymentRequestRecipient)) }, + onDetails = { + navController.navigateTo( + Routes.SubscriptionDetail( + paymentRequestId = it.paymentRequestId, + counterparty = it.counterparty, + counterpartyReceiverPath = it.counterpartyReceiverPath, + ) + ) + }, + onPaymentRequestDetails = { navController.navigateTo(it.toRoute()) }, + showPayments = route.showPayments, + ) + } + } + composableWithDefaultTransitions { backStackEntry -> + PaykitRouteGuard(settingsViewModel, navController) { + val route = backStackEntry.toRoute() + SubscriptionDetailScreen( + appViewModel = appViewModel, + id = PaykitSubscriptionId( + paymentRequestId = route.paymentRequestId, + counterparty = route.counterparty, + counterpartyReceiverPath = route.counterpartyReceiverPath, + ), + onBack = { navController.popBackStack() }, + ) + } + } + composableWithDefaultTransitions { backStackEntry -> + PaykitRouteGuard(settingsViewModel, navController) { + val route = backStackEntry.toRoute() + IncomingPaymentRequestDetailsScreen( + appViewModel = appViewModel, + id = route.toId(), + onBack = { navController.popBackStack() }, ) } } @@ -1267,6 +1324,10 @@ private fun NavGraphBuilder.contacts( PaykitRouteGuard(settingsViewModel, navController) { val route = backStackEntry.toRoute() val viewModel: ContactDetailViewModel = hiltViewModel() + val paymentRequestTargets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() + val paymentRequestTarget = paymentRequestTargets.firstOrNull { + PubkyPublicKeyFormat.matches(it.publicKey, route.publicKey) + } ContactDetailScreen( viewModel = viewModel, onBackClick = { navController.popBackStack() }, @@ -1274,6 +1335,19 @@ private fun NavGraphBuilder.contacts( appViewModel.openContactPayment(paymentRequest, publicKey, privatePaymentContext) }, onActivityClick = { navController.navigateTo(Routes.ContactActivity(it)) }, + canRequestPayment = paymentRequestTarget != null, + onRequestPayment = { + paymentRequestTarget?.let { + appViewModel.showSheet( + Sheet.Receive( + route = ReceiveRoute.PaymentRequestAmount( + publicKey = it.publicKey, + receiverPath = it.receiverPath, + ) + ) + ) + } + }, showDeleteAction = route.showDeleteAction, onContactDeleted = { navController.navigateTo(Routes.Contacts()) { popUpTo(Routes.Home) } @@ -2000,6 +2074,20 @@ fun NavController.navigateToLanguageSettings() = navigateTo(Routes.LanguageSetti // endregion +private fun PaykitPaymentRequestId.toRoute() = Routes.PaymentRequestDetails( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + billingPeriodStartsAt = billingPeriodStartsAt, +) + +private fun Routes.PaymentRequestDetails.toId() = PaykitPaymentRequestId( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + billingPeriodStartsAt = billingPeriodStartsAt, +) + @Stable sealed interface Routes { sealed interface DeepLinkable : Routes @@ -2321,7 +2409,22 @@ sealed interface Routes { data object AllActivity : Routes.DeepLinkable @Serializable - data object PaymentRequests : Routes.InternalOnly + data class Subscriptions(val showPayments: Boolean = false) : Routes.InternalOnly + + @Serializable + data class SubscriptionDetail( + val paymentRequestId: String, + val counterparty: String, + val counterpartyReceiverPath: String, + ) : Routes.InternalOnly + + @Serializable + data class PaymentRequestDetails( + val paymentRequestId: String, + val counterparty: String, + val counterpartyReceiverPath: String, + val billingPeriodStartsAt: String? = null, + ) : Routes.InternalOnly @Serializable data object Trezor : Routes.DeepLinkable diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index d3ad823396..cb122b7244 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -39,6 +39,7 @@ import to.bitkit.androidServices.LightningNodeService.Companion.ACTION_START_SER import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.SamRockSetupRequest +import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.ui.components.AuthCheckView import to.bitkit.ui.components.IsOnlineTracker import to.bitkit.ui.components.ToastOverlay @@ -230,6 +231,13 @@ class MainActivity : FragmentActivity() { } private fun handleLaunchIntent(intent: Intent) { + if (intent.getBooleanExtra(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE, false)) { + intent.removeExtra(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE) + appViewModel.onPaykitSubscriptionNotificationTapped( + payerIdentity = intent.getStringExtra(EXTRA_PAYKIT_PAYER_IDENTITY), + requestId = intent.paykitPaymentRequestId(), + ) + } if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED) { handleUsbAttachIntent(intent) return @@ -242,6 +250,20 @@ class MainActivity : FragmentActivity() { } } + private fun Intent.paykitPaymentRequestId(): PaykitPaymentRequestId? { + val requestId = getStringExtra(EXTRA_PAYKIT_PAYMENT_REQUEST_ID) ?: return null + val counterparty = getStringExtra(EXTRA_PAYKIT_COUNTERPARTY) ?: return null + val receiverPath = getStringExtra(EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH) ?: return null + val billingPeriodStartsAt = getStringExtra(EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT) ?: return null + + return PaykitPaymentRequestId( + paymentRequestId = requestId, + counterparty = counterparty, + counterpartyReceiverPath = receiverPath, + billingPeriodStartsAt = billingPeriodStartsAt, + ) + } + /** * The OS delivers the USB attach event as an activity intent (via the app picker), * not as a broadcast, so it is forwarded from here to trigger the silent reconnect. diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index ec9e352936..1e8d7b9668 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -26,6 +26,12 @@ import kotlin.random.Random const val ID_NOTIFICATION_SKIPPED = -1 const val ID_NOTIFICATION_NODE = 1 +const val EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE = "paykit_subscription_payment_due" +const val EXTRA_PAYKIT_PAYER_IDENTITY = "paykit_payer_identity" +const val EXTRA_PAYKIT_PAYMENT_REQUEST_ID = "paykit_payment_request_id" +const val EXTRA_PAYKIT_COUNTERPARTY = "paykit_counterparty" +const val EXTRA_PAYKIT_COUNTERPARTY_RECEIVER_PATH = "paykit_counterparty_receiver_path" +const val EXTRA_PAYKIT_BILLING_PERIOD_STARTS_AT = "paykit_billing_period_starts_at" val Context.CHANNEL_MAIN get() = getString(R.string.app_notifications_channel_id) @@ -42,6 +48,7 @@ fun Context.initNotificationChannel( internal fun Context.notificationBuilder( extra: Bundle? = null, channelId: String = CHANNEL_MAIN, + requestCode: Int = 0, ): NotificationCompat.Builder { val intent = Intent(this, MainActivity::class.java).apply { flags = FLAG_ACTIVITY_CLEAR_TOP @@ -49,7 +56,7 @@ internal fun Context.notificationBuilder( } val flags = FLAG_IMMUTABLE or FLAG_ONE_SHOT - val pendingIntent = PendingIntent.getActivity(this, 0, intent, flags) + val pendingIntent = PendingIntent.getActivity(this, requestCode, intent, flags) return NotificationCompat.Builder(this, channelId) .setSmallIcon(R.drawable.ic_bitkit_outlined) @@ -74,7 +81,7 @@ internal fun Context.pushNotification( requiresPermission(permission.POST_NOTIFICATIONS) if (!needsPermissionGrant) { - val builder = notificationBuilder(extras) + val builder = notificationBuilder(extras, requestCode = id) .setContentTitle(title) .setContentText(text) .apply { diff --git a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt index a0dc4053d6..9e1f5e7d25 100644 --- a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt +++ b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt @@ -60,7 +60,7 @@ private const val Z_INDEX_SCRIM = 10f private const val Z_INDEX_MENU = 11f private val bgScrim = Colors.Black50 private val drawerBg = Colors.Brand -private val drawerWidth = 200.dp +private val drawerWidth = 260.dp @Composable fun DrawerMenu( @@ -185,7 +185,7 @@ fun DrawerMenu( onBeforeNavigate(Routes.Home) onOpenWalletHome() }, - showPaymentRequests = isPaykitEnabled, + showSubscriptions = isPaykitEnabled, onBeforeNavigate = onBeforeNavigate, ) } @@ -200,7 +200,7 @@ private fun Menu( onClickContacts: () -> Unit, onClickProfile: () -> Unit, onClickWallet: () -> Unit, - showPaymentRequests: Boolean, + showSubscriptions: Boolean, onBeforeNavigate: (Routes?) -> Unit, ) { val scope = rememberCoroutineScope() @@ -235,16 +235,17 @@ private fun Menu( modifier = Modifier.testTag("DrawerActivity") ) - if (showPaymentRequests) { + if (showSubscriptions) { DrawerItem( - label = stringResource(R.string.wallet__drawer__payment_requests), - iconRes = R.drawable.ic_file_text, + label = stringResource(R.string.subscriptions__title), + iconRes = R.drawable.ic_arrows_clockwise, onClick = { - onBeforeNavigate(Routes.PaymentRequests) - rootNavController.navigateIfNotCurrent(Routes.PaymentRequests) + val route = Routes.Subscriptions() + onBeforeNavigate(route) + rootNavController.navigateIfNotCurrent(route) scope.launch { drawerState.close() } }, - modifier = Modifier.testTag("DrawerPaymentRequests") + modifier = Modifier.testTag("DrawerSubscriptions") ) } diff --git a/app/src/main/java/to/bitkit/ui/components/Money.kt b/app/src/main/java/to/bitkit/ui/components/Money.kt index adffc87ba7..0759a20ddf 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -46,6 +46,7 @@ fun MoneyDisplay( fun MoneyCell( sats: Long, modifier: Modifier = Modifier, + prefix: String = "", ) { val currencies = LocalCurrencies.current Column( @@ -55,7 +56,7 @@ fun MoneyCell( ) { rememberMoneyText(sats = sats, unit = currencies.primaryDisplay, showSymbol = true)?.let { text -> BodyMSB( - text = text.withAccent(accentColor = Colors.White64), + text = "$prefix$text".withAccent(accentColor = Colors.White64), modifier = Modifier.testTag("MoneyPrimary"), ) } diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 961637aa32..87d16ee23a 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import kotlinx.coroutines.launch import to.bitkit.models.SamRockSetupRequest +import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.sheets.BackupRoute @@ -52,11 +53,19 @@ enum class SheetHandlePlacement { ContentOverlay, } +sealed interface SubscriptionRoute { + data class Review(val id: PaykitSubscriptionId) : SubscriptionRoute + data class Success(val id: PaykitSubscriptionId) : SubscriptionRoute + data class Details(val id: PaykitSubscriptionId) : SubscriptionRoute + data class Cancel(val id: PaykitSubscriptionId) : SubscriptionRoute +} + @Stable sealed interface Sheet { data class Send(val route: SendRoute = SendRoute.Recipient) : Sheet data class Receive(val route: ReceiveRoute = ReceiveRoute.QR) : Sheet data object PaymentRequests : Sheet + data class Subscription(val route: SubscriptionRoute) : Sheet data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet data object ChangePin : Sheet data object DisablePin : Sheet @@ -126,14 +135,15 @@ fun SheetHost( LaunchedEffect(scaffoldState.bottomSheetState.isVisible, visibilityKey) { if (scaffoldState.bottomSheetState.isVisible) { wasSheetVisible = true - if (visibleKey != visibilityKey) { + if (currentShouldExpand && visibleKey != visibilityKey) { visibleKey = visibilityKey onVisible() } } else if (wasSheetVisible) { + val dismissedKey = visibleKey wasSheetVisible = false visibleKey = null - onDismiss() + if (dismissedKey == visibilityKey) onDismiss() } } diff --git a/app/src/main/java/to/bitkit/ui/components/Tag.kt b/app/src/main/java/to/bitkit/ui/components/Tag.kt index f69178b0fe..18e1aadb2b 100644 --- a/app/src/main/java/to/bitkit/ui/components/Tag.kt +++ b/app/src/main/java/to/bitkit/ui/components/Tag.kt @@ -12,9 +12,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow @@ -74,6 +80,40 @@ fun TagButton( } } +@Composable +fun AddTagButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val cornerRadius = 8.dp + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .clip(AppShapes.small) + .drawBehind { + drawRoundRect( + color = Colors.White64, + style = Stroke( + width = 1.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 4f)), + ), + cornerRadius = CornerRadius(cornerRadius.toPx()), + ) + } + .clickableAlpha(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + BodySSB(text = stringResource(R.string.wallet__tags_add_button), color = Colors.White) + Icon( + painter = painterResource(R.drawable.ic_plus), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(16.dp), + ) + } +} + @Preview @Composable private fun Preview() { 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 7cb936a49e..c3a252d293 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 @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.contacts +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -8,14 +9,20 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon 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.LocalContext @@ -34,10 +41,15 @@ import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.ui.components.ActionButton import to.bitkit.ui.components.AddTagSheet import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheet import to.bitkit.ui.components.CenteredProfileHeader +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.LinkRow +import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.SheetSize import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer @@ -45,9 +57,13 @@ import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.shared.util.shareText import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent @Composable fun ContactDetailScreen( @@ -55,12 +71,15 @@ fun ContactDetailScreen( onBackClick: () -> Unit, onPayContact: (String, String, PrivatePaykitPaymentContext?) -> Unit, onActivityClick: (String) -> Unit, + canRequestPayment: Boolean = false, + onRequestPayment: () -> Unit = {}, showDeleteAction: Boolean = false, onContactDeleted: () -> Unit = {}, onEditContact: (String) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + var showRequestOrPay by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.effects.collect { @@ -79,7 +98,13 @@ fun ContactDetailScreen( showDeleteAction = showDeleteAction, onClickDelete = { viewModel.showDeleteConfirmation() }, onClickCopy = { viewModel.copyPublicKey() }, - onClickPay = { viewModel.payContact() }, + onClickPay = { + if (canRequestPayment) { + showRequestOrPay = true + } else { + viewModel.payContact() + } + }, onClickActivity = { uiState.profile?.publicKey?.let { onActivityClick(it) } }, onClickShare = { uiState.profile?.publicKey?.let { shareText(context, it) } }, onClickRetry = { viewModel.loadContact() }, @@ -90,6 +115,89 @@ fun ContactDetailScreen( onDismissDeleteDialog = { viewModel.dismissDeleteConfirmation() }, onConfirmDelete = { viewModel.deleteContact() }, ) + + if (showRequestOrPay && uiState.profile != null) { + RequestOrPaySheet( + contact = requireNotNull(uiState.profile), + onDismiss = { showRequestOrPay = false }, + onPay = { + showRequestOrPay = false + viewModel.payContact() + }, + onRequest = { + showRequestOrPay = false + onRequestPayment() + }, + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun RequestOrPaySheet( + contact: PubkyProfile, + onDismiss: () -> Unit, + onPay: () -> Unit, + onRequest: () -> Unit, +) { + BottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .sheetHeight(SheetSize.MEDIUM, isModal = true) + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("RequestOrPaySheet") + ) { + SheetTopBar(titleText = stringResource(R.string.wallet__payment_request_or_pay)) + FillHeight() + Image( + painter = painterResource(R.drawable.coin_stack), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.CenterHorizontally), + ) + FillHeight() + Display( + text = stringResource(R.string.wallet__payment_request_or_pay_headline) + .withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(12.dp) + BodyM( + text = stringResource(R.string.wallet__payment_request_or_pay_description, contact.name), + color = Colors.White64, + ) + VerticalSpacer(24.dp) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SecondaryButton( + text = stringResource(R.string.wallet__payment_request_pay), + onClick = onPay, + icon = { + Icon( + painter = painterResource(R.drawable.ic_sent), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + modifier = Modifier.weight(1f), + ) + PrimaryButton( + text = stringResource(R.string.wallet__payment_request_request), + onClick = onRequest, + icon = { + Icon( + painter = painterResource(R.drawable.ic_received), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + modifier = Modifier.weight(1f), + ) + } + VerticalSpacer(16.dp) + } + } } @Composable diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt index 94e1d431a1..0628bf6a58 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -3,9 +3,10 @@ package to.bitkit.ui.screens.paymentrequests -import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -30,10 +32,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -45,7 +43,6 @@ import to.bitkit.R import to.bitkit.ext.getClipboardText import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat -import to.bitkit.repositories.AmountInputHandler import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestDraft @@ -58,19 +55,25 @@ import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.components.MoneyCell +import to.bitkit.ui.components.MoneyDisplay import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.PubkyContactRow import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.UnitButton import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.rememberMoneyText import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground -import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.removeAccentTags import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.AppViewModel @@ -99,35 +102,33 @@ enum class PaymentRequestExpiration(val duration: Duration) { } @Composable -fun PaymentRequestDetailsScreen( +fun PaymentRequestAmountScreen( amountInputViewModel: AmountInputViewModel, initialDraft: PaykitPaymentRequestDraft, + contact: PubkyProfile?, onBack: () -> Unit, onContinue: (PaykitPaymentRequestDraft) -> Unit, ) { - PaymentRequestDetailsContent( + PaymentRequestAmountContent( amountInputViewModel = amountInputViewModel, initialDraft = initialDraft, + contact = contact, onBack = onBack, onContinue = onContinue, ) } @Composable -internal fun PaymentRequestDetailsContent( +internal fun PaymentRequestAmountContent( modifier: Modifier = Modifier, amountInputViewModel: AmountInputViewModel, initialDraft: PaykitPaymentRequestDraft, + contact: PubkyProfile?, onBack: () -> Unit, onContinue: (PaykitPaymentRequestDraft) -> Unit, ) { val currencies = LocalCurrencies.current val amountState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - var note by remember(initialDraft.note) { mutableStateOf(initialDraft.note) } - var isEditingAmount by remember { mutableStateOf(false) } - var expiration by remember(initialDraft.expiresAt) { - mutableStateOf(PaymentRequestExpiration.from(initialDraft.expiresAt, Clock.System.now())) - } LaunchedEffect(initialDraft.amountSats) { amountInputViewModel.setSats( @@ -136,6 +137,115 @@ internal fun PaymentRequestDetailsContent( ) } + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("PaymentRequestAmount") + ) { + SheetTopBar( + titleText = stringResource(R.string.wallet__payment_request_amount), + onBack = onBack, + action = contact?.let { + { + PubkyContactAvatar( + profile = it, + size = 32.dp, + modifier = Modifier.padding(end = 8.dp), + ) + } + }, + ) + BoxWithConstraints(modifier = Modifier.weight(1f)) { + val availableHeight = this.maxHeight + + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + VerticalSpacer(16.dp) + rememberMoneyText(sats = amountState.sats, reversed = true, showSymbol = true)?.let { + Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + } + VerticalSpacer(8.dp) + NumberPadTextField( + viewModel = amountInputViewModel, + modifier = Modifier + .fillMaxWidth() + .testTag("PaymentRequestAmountField"), + ) + FillHeight(min = 12.dp) + Row(modifier = Modifier.fillMaxWidth()) { + FillWidth() + UnitButton( + onClick = { amountInputViewModel.switchUnit(currencies) }, + color = Colors.Brand, + modifier = Modifier.testTag("PaymentRequestAmountUnit"), + ) + } + VerticalSpacer(16.dp) + HorizontalDivider(color = Colors.White10) + NumberPad( + viewModel = amountInputViewModel, + currencies = currencies, + availableHeight = availableHeight, + modifier = Modifier.testTag("PaymentRequestNumberPad"), + ) + PrimaryButton( + text = stringResource(R.string.common__continue), + enabled = amountState.sats > 0, + onClick = { + onContinue(initialDraft.copy(amountSats = amountState.sats.toULong())) + }, + modifier = Modifier.testTag("PaymentRequestAmountContinue"), + ) + VerticalSpacer(16.dp) + } + } + } +} + +@Composable +fun PaymentRequestDetailsScreen( + appViewModel: AppViewModel, + draft: PaykitPaymentRequestDraft, + target: PaykitPaymentRequestTarget, + onBack: () -> Unit, + onEditAmount: (PaykitPaymentRequestDraft) -> Unit, + onSent: (PaykitPaymentRequest) -> Unit, +) { + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + val contact = contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, target.publicKey) } + ?: PubkyProfile.placeholder(target.publicKey) + + PaymentRequestDetailsContent( + initialDraft = draft, + contact = contact, + isCreating = isCreating, + onBack = onBack, + onEditAmount = onEditAmount, + onSend = { updatedDraft -> appViewModel.createPaymentRequest(updatedDraft, target, onSent) }, + ) +} + +@Composable +internal fun PaymentRequestDetailsContent( + initialDraft: PaykitPaymentRequestDraft, + contact: PubkyProfile, + isCreating: Boolean, + onBack: () -> Unit, + onEditAmount: (PaykitPaymentRequestDraft) -> Unit, + onSend: (PaykitPaymentRequestDraft) -> Unit, + modifier: Modifier = Modifier, +) { + var note by remember(initialDraft.note) { mutableStateOf(initialDraft.note) } + var expiration by remember(initialDraft.expiresAt) { + mutableStateOf(PaymentRequestExpiration.from(initialDraft.expiresAt, Clock.System.now())) + } + fun updatedDraft(trimNote: Boolean = false) = initialDraft.copy( + note = if (trimNote) note.trim() else note, + expiresAt = Clock.System.now() + expiration.duration, + ) + Column( modifier = modifier .fillMaxSize() @@ -148,22 +258,25 @@ internal fun PaymentRequestDetailsContent( titleText = stringResource(R.string.wallet__payment_request), onBack = onBack, ) - Caption13Up(text = stringResource(R.string.wallet__payment_request_amount), color = Colors.White64) - VerticalSpacer(8.dp) + rememberMoneyText( + sats = initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + reversed = true, + showSymbol = true, + )?.let { + Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - NumberPadTextField( - viewModel = amountInputViewModel, - onClick = { isEditingAmount = true }, - modifier = Modifier - .weight(1f) - .testTag("PaymentRequestAmountField"), + MoneyDisplay( + sats = initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + showSymbol = true, ) + FillWidth() IconButton( - onClick = { isEditingAmount = true }, + onClick = { onEditAmount(updatedDraft()) }, modifier = Modifier .size(48.dp) .testTag("PaymentRequestEditAmount"), @@ -176,74 +289,76 @@ internal fun PaymentRequestDetailsContent( ) } } - if (isEditingAmount) { - FillHeight() - NumberPad( - viewModel = amountInputViewModel, - availableHeight = 210.dp, - modifier = Modifier.testTag("PaymentRequestNumberPad"), - ) - VerticalSpacer(12.dp) - PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = { isEditingAmount = false }, - modifier = Modifier.testTag("PaymentRequestAmountDone"), - ) - } else { - VerticalSpacer(20.dp) - Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) - VerticalSpacer(8.dp) - TextInput( - value = note, - onValueChange = { note = it.take(256) }, - placeholder = stringResource(R.string.wallet__payment_request_note_placeholder), - maxLines = 2, - modifier = Modifier - .fillMaxWidth() - .testTag("PaymentRequestNote"), - ) - VerticalSpacer(20.dp) - Caption13Up(text = stringResource(R.string.wallet__payment_request_expires), color = Colors.White64) - VerticalSpacer(8.dp) - Row(modifier = Modifier.fillMaxWidth()) { - PaymentRequestExpiration.entries.forEach { option -> - val isSelected = option == expiration - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .weight(1f) - .clickableAlpha { expiration = option } - .semantics { - role = Role.RadioButton - selected = isSelected - } - .testTag("PaymentRequestExpiry${option.name}"), - ) { - BodyS(text = option.title(), color = if (isSelected) Colors.White else Colors.White64) - VerticalSpacer(8.dp) - HorizontalDivider( - thickness = 2.dp, - color = if (isSelected) Colors.White else Colors.White16, - ) - } - } + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = note, + onValueChange = { note = it.take(256) }, + placeholder = stringResource(R.string.wallet__payment_request_note_placeholder), + maxLines = 2, + modifier = Modifier + .fillMaxWidth() + .testTag("PaymentRequestNote"), + ) + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) + VerticalSpacer(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray6, RoundedCornerShape(16.dp)) + .padding(16.dp), + ) { + PubkyContactAvatar(profile = contact, size = 40.dp) + Column(modifier = Modifier.padding(start = 16.dp).weight(1f)) { + BodyMSB(text = contact.name, maxLines = 1) + BodyS( + text = note.ifBlank { stringResource(R.string.wallet__payment_request) }, + color = Colors.White64, + maxLines = 1, + ) } - FillHeight() - PrimaryButton( - text = stringResource(R.string.wallet__payment_request_choose_recipient), - enabled = amountState.sats > 0, - onClick = { - onContinue( - PaykitPaymentRequestDraft( - amountSats = amountState.sats.toULong(), - note = note.trim(), - expiresAt = Clock.System.now() + expiration.duration, - ) + MoneyCell(sats = initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()) + } + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_expires), color = Colors.White64) + VerticalSpacer(8.dp) + Row(modifier = Modifier.fillMaxWidth()) { + PaymentRequestExpiration.entries.forEach { option -> + val isSelected = option == expiration + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .clickableAlpha { expiration = option } + .testTag("PaymentRequestExpiry${option.name}"), + ) { + BodyS(text = option.title(), color = if (isSelected) Colors.White else Colors.White64) + VerticalSpacer(8.dp) + HorizontalDivider( + thickness = 2.dp, + color = if (isSelected) Colors.White else Colors.White16, ) - }, - modifier = Modifier.testTag("PaymentRequestAmountContinue"), - ) + } + } } + FillHeight() + PrimaryButton( + text = stringResource(R.string.wallet__payment_request_send_request), + enabled = !isCreating, + isLoading = isCreating, + onClick = { onSend(updatedDraft(trimNote = true)) }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_sent), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + modifier = Modifier.testTag("PaymentRequestSend"), + ) VerticalSpacer(16.dp) } } @@ -251,22 +366,19 @@ internal fun PaymentRequestDetailsContent( @Composable fun PaymentRequestRecipientScreen( appViewModel: AppViewModel, - draft: PaykitPaymentRequestDraft, - onEditExpiration: () -> Unit, - onSent: (PaykitPaymentRequest) -> Unit, + onBack: () -> Unit, + onSelected: (PaykitPaymentRequestTarget) -> Unit, ) { val context = LocalContext.current val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() - val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() PaymentRequestRecipientContent( targets = targets.toImmutableList(), contacts = contacts.toImmutableList(), - isCreating = isCreating, - onEditExpiration = onEditExpiration, + onBack = onBack, onPaste = { context.getClipboardText()?.trim().orEmpty() }, - onSend = { target -> appViewModel.createPaymentRequest(draft, target, onSent) }, + onSelected = onSelected, ) } @@ -275,12 +387,10 @@ internal fun PaymentRequestRecipientContent( modifier: Modifier = Modifier, targets: ImmutableList, contacts: ImmutableList, - isCreating: Boolean, - onEditExpiration: () -> Unit, + onBack: () -> Unit, onPaste: () -> String, - onSend: (PaykitPaymentRequestTarget) -> Unit, + onSelected: (PaykitPaymentRequestTarget) -> Unit, ) { - var selectedTarget by remember { mutableStateOf(null) } var query by remember { mutableStateOf("") } val recipients = remember(targets, contacts, query) { @@ -294,11 +404,6 @@ internal fun PaymentRequestRecipientContent( } } - LaunchedEffect(recipients) { - if (recipients.none { (target, _) -> target == selectedTarget }) selectedTarget = null - } - BackHandler(enabled = isCreating) {} - Column( modifier = modifier .fillMaxSize() @@ -309,20 +414,7 @@ internal fun PaymentRequestRecipientContent( ) { SheetTopBar( titleText = stringResource(R.string.wallet__payment_request_choose_recipient), - action = { - IconButton( - onClick = onEditExpiration, - enabled = !isCreating, - modifier = Modifier.testTag("PaymentRequestEditExpiration"), - ) { - Icon( - painter = painterResource(R.drawable.ic_timer), - contentDescription = stringResource(R.string.wallet__payment_request_edit_expiration), - tint = Colors.White, - modifier = Modifier.size(24.dp), - ) - } - }, + onBack = onBack, ) Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) VerticalSpacer(8.dp) @@ -331,7 +423,6 @@ internal fun PaymentRequestRecipientContent( onValueChange = { query = it }, placeholder = stringResource(R.string.wallet__payment_request_enter_pubky), singleLine = true, - textStyle = AppTextStyles.BodyM, trailingIcon = { Row( verticalAlignment = Alignment.CenterVertically, @@ -367,27 +458,13 @@ internal fun PaymentRequestRecipientContent( ) { (target, contact) -> PubkyContactRow( profile = contact, - onClick = { selectedTarget = target }, - isSelected = target == selectedTarget, - isEnabled = !isCreating, + onClick = { onSelected(target) }, verticalPadding = 16.dp, - selectionColor = Colors.Brand, modifier = Modifier.testTag("PaymentRequestContact${contact.publicKey}"), ) HorizontalDivider(color = Colors.White10) } } - PrimaryButton( - text = stringResource(R.string.wallet__payment_request_send_request), - enabled = !isCreating && selectedTarget != null && selectedTarget in targets, - isLoading = isCreating, - onClick = { - val target = selectedTarget ?: return@PrimaryButton - onSend(target) - }, - modifier = Modifier.testTag("PaymentRequestSend"), - ) - VerticalSpacer(16.dp) } } @@ -444,7 +521,9 @@ internal fun PaymentRequestSentContent( PaymentRequestCard( request = request, contact = contact, - compactSubtitle = if (request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + compactSubtitle = request.note?.takeIf(String::isNotBlank) ?: if ( + request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent + ) { stringResource(R.string.wallet__payment_request_waiting) } else { stringResource(R.string.wallet__payment_request_sending) @@ -498,10 +577,12 @@ private fun PaymentRequestDetailsPreview() { AppThemeSurface { BottomSheetPreview { PaymentRequestDetailsContent( - amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), initialDraft = previewDraft, + contact = PubkyProfile.placeholder(previewTarget.publicKey), + isCreating = false, onBack = {}, - onContinue = {}, + onEditAmount = {}, + onSend = {}, modifier = Modifier.sheetHeight(), ) } @@ -516,10 +597,9 @@ private fun PaymentRequestRecipientPreview() { PaymentRequestRecipientContent( targets = persistentListOf(previewTarget), contacts = persistentListOf(PubkyProfile.placeholder(previewTarget.publicKey)), - isCreating = false, - onEditExpiration = {}, + onBack = {}, onPaste = { "" }, - onSend = {}, + onSelected = {}, modifier = Modifier.sheetHeight(), ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt new file mode 100644 index 0000000000..6124a413a2 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt @@ -0,0 +1,334 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.paymentrequests + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.synonym.paykit.PaymentRequestLifecycleState +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.ext.UiDateStyle +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDirection +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.ui.components.AddTagButton +import to.bitkit.ui.components.AddTagSheet +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TagButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.rememberMoneyText +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.screens.wallets.activity.components.CircularIcon +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.removeAccentTags +import to.bitkit.ui.utils.uiDateText +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AppViewModel +import kotlin.time.ExperimentalTime + +@Composable +fun IncomingPaymentRequestDetailsScreen( + appViewModel: AppViewModel, + id: PaykitPaymentRequestId, + onBack: () -> Unit, +) { + val pending by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() + val history by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val request = pending.firstOrNull { it.id == id } ?: history.firstOrNull { it.id == id } + val contact = request?.let { paymentRequest -> + contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, paymentRequest.counterparty) } + ?: PubkyProfile.placeholder(paymentRequest.counterparty) + } + val isPending = pending.any { it.id == id } + + IncomingPaymentRequestDetailsContent( + request = request, + contact = contact, + isPending = isPending, + onBack = onBack, + onPay = { appViewModel.openIncomingPaymentRequestWithTags(id, it) }, + onDismiss = request?.let { { appViewModel.dismissIncomingPaymentRequest(it) } }, + ) +} + +@Composable +private fun IncomingPaymentRequestDetailsContent( + request: PaykitPaymentRequest?, + contact: PubkyProfile?, + isPending: Boolean, + onBack: () -> Unit, + onPay: (List) -> Unit, + onDismiss: (suspend () -> Result)?, +) { + val scope = rememberCoroutineScope() + var isDismissing by remember(request?.id) { mutableStateOf(false) } + var selectedTags by remember(request?.id) { mutableStateOf(emptyList()) } + var isAddingTag by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("PaymentRequestDetailsScreen") + ) { + AppTopBar( + titleText = stringResource(R.string.wallet__payment_request), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + if (request == null || contact == null) { + FillHeight() + BodyM( + text = stringResource(R.string.wallet__payment_request_status_unavailable), + color = Colors.White64, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + FillHeight() + return@Column + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + ) { + VerticalSpacer(16.dp) + rememberMoneyText( + sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + reversed = true, + showSymbol = true, + )?.let { + Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + } + rememberMoneyText( + sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + showSymbol = true, + )?.let { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Display( + text = "${request.detailsAmountPrefix()}$it".withAccent(accentColor = Colors.White64), + ) + FillWidth() + PaymentRequestDetailsIcon(request) + } + } + VerticalSpacer(24.dp) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + RequestDetailCell( + title = stringResource(R.string.wallet__payment_request_date), + value = request.createdAt?.let { uiDateText(it.epochSeconds.toULong(), UiDateStyle.DATE) } + ?: stringResource(R.string.wallet__payment_request_status_unavailable), + iconRes = R.drawable.ic_calendar, + modifier = Modifier.weight(1f), + ) + RequestDetailCell( + title = stringResource(R.string.wallet__payment_request_time), + value = request.createdAt?.let { uiDateText(it.epochSeconds.toULong(), UiDateStyle.TIME) } + ?: stringResource(R.string.wallet__payment_request_status_unavailable), + iconRes = R.drawable.ic_clock, + modifier = Modifier.weight(1f), + ) + } + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_contact), color = Colors.White64) + VerticalSpacer(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray6, RoundedCornerShape(16.dp)) + .padding(16.dp), + ) { + PubkyContactAvatar(profile = contact, size = 40.dp) + BodyMSB(text = contact.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + VerticalSpacer(20.dp) + PaymentRequestTags( + tags = selectedTags, + onRemove = { selectedTags -= it }, + onAdd = { isAddingTag = true }, + ) + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) + VerticalSpacer(8.dp) + BodyMSB( + text = request.note ?: stringResource(R.string.wallet__payment_request), + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray6, RoundedCornerShape(16.dp)) + .padding(16.dp), + ) + } + + if (isPending) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + ) { + SecondaryButton( + text = stringResource(R.string.wallet__payment_request_dismiss), + enabled = !isDismissing, + isLoading = isDismissing, + onClick = { + val dismiss = onDismiss ?: return@SecondaryButton + isDismissing = true + scope.launch { + dismiss().onSuccess { onBack() } + isDismissing = false + } + }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_x), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + modifier = Modifier.weight(1f), + ) + PrimaryButton( + text = stringResource(R.string.wallet__payment_request_pay), + enabled = !isDismissing, + onClick = { onPay(selectedTags) }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_coins), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + modifier = Modifier.weight(1f), + ) + } + } + } + + if (isAddingTag) { + AddTagSheet( + onDismiss = { isAddingTag = false }, + onSave = { tag -> + selectedTags = (selectedTags + tag.trim()).filter(String::isNotBlank).distinct() + isAddingTag = false + }, + ) + } +} + +@Composable +private fun PaymentRequestTags( + tags: List, + onRemove: (String) -> Unit, + onAdd: () -> Unit, +) { + Caption13Up(text = stringResource(R.string.wallet__tags), color = Colors.White64) + VerticalSpacer(8.dp) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + tags.forEach { tag -> + TagButton( + text = tag, + displayIconClose = true, + onClick = { onRemove(tag) }, + ) + } + AddTagButton( + onClick = onAdd, + modifier = Modifier.testTag("PaymentRequestAddTag"), + ) + } +} + +private fun PaykitPaymentRequest.detailsAmountPrefix(): String = + if (direction == PaykitPaymentRequestDirection.Incoming) "-" else "+" + +@Composable +private fun PaymentRequestDetailsIcon(request: PaykitPaymentRequest) { + val isCompleted = request.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED + val isIncomingRequest = request.direction == PaykitPaymentRequestDirection.Incoming + CircularIcon( + icon = painterResource( + if (isCompleted == isIncomingRequest) R.drawable.ic_sent else R.drawable.ic_received + ), + iconColor = when { + isCompleted -> request.paymentRailIconColor + isIncomingRequest -> Colors.Purple + else -> Colors.Brand + }, + backgroundColor = when { + isCompleted -> request.paymentRailBackgroundColor + isIncomingRequest -> Colors.Purple16 + else -> Colors.Brand16 + }, + size = 48.dp, + ) +} + +@Composable +private fun RequestDetailCell( + title: String, + value: String, + @androidx.annotation.DrawableRes iconRes: Int, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Caption13Up(text = title, color = Colors.White64) + VerticalSpacer(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = Colors.Purple, + modifier = Modifier.size(16.dp), + ) + BodySSB(text = value, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + VerticalSpacer(12.dp) + HorizontalDivider(color = Colors.White10) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index e04df26731..96dac46f51 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -45,10 +45,12 @@ import to.bitkit.R import to.bitkit.ext.UiDateStyle import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestDirection import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitSubscription import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS @@ -65,6 +67,8 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.wallets.activity.components.CircularIcon +import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.shared.util.outerGlow @@ -87,9 +91,11 @@ fun PaymentRequestsSheet( appViewModel: AppViewModel, onNotNow: () -> Unit, onSeeAll: () -> Unit, + onDetails: (PaykitPaymentRequestId) -> Unit, ) { val requests by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() LaunchedEffect(requests.isEmpty()) { if (requests.isEmpty()) onNotNow() @@ -98,10 +104,12 @@ fun PaymentRequestsSheet( PaymentRequestsSheetContent( requests = requests.toImmutableList(), contacts = contacts.toImmutableList(), + subscriptions = subscriptions.toImmutableList(), onNotNow = onNotNow, onSeeAll = onSeeAll, onPay = appViewModel::openIncomingPaymentRequest, - onReject = appViewModel::rejectIncomingPaymentRequest, + onDismiss = appViewModel::dismissIncomingPaymentRequest, + onDetails = onDetails, ) } @@ -110,10 +118,12 @@ internal fun PaymentRequestsSheetContent( modifier: Modifier = Modifier, requests: ImmutableList, contacts: ImmutableList, + subscriptions: ImmutableList, onNotNow: () -> Unit, onSeeAll: () -> Unit, onPay: (PaykitPaymentRequestId) -> Unit, - onReject: suspend (PaykitPaymentRequest) -> Result, + onDismiss: suspend (PaykitPaymentRequest) -> Result, + onDetails: (PaykitPaymentRequestId) -> Unit, ) { Column( modifier = modifier @@ -139,8 +149,10 @@ internal fun PaymentRequestsSheetContent( PaymentRequestCard( request = request, contact = contacts.contactFor(request), + compactSubtitle = subscriptions.nameFor(request), + onClick = { onDetails(request.id) }, onPay = { onPay(request.id) }, - onReject = { onReject(request) }, + onDismiss = { onDismiss(request) }, ) } } @@ -167,21 +179,27 @@ fun PaymentRequestsScreen( appViewModel: AppViewModel, onBack: () -> Unit, onRequestPayment: () -> Unit, + onDetails: (PaykitPaymentRequestId) -> Unit, + showsNavigationBar: Boolean = true, ) { val pending by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() val history by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() + val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() PaymentRequestsContent( requests = (pending + history).distinctBy { it.id }.toImmutableList(), pending = pending.toImmutableList(), contacts = contacts.toImmutableList(), + subscriptions = subscriptions.toImmutableList(), canRequestPayment = targets.isNotEmpty(), onBack = onBack, onRequestPayment = onRequestPayment, onPay = appViewModel::openIncomingPaymentRequest, - onReject = appViewModel::rejectIncomingPaymentRequest, + onDismiss = appViewModel::dismissIncomingPaymentRequest, + onDetails = onDetails, + showsNavigationBar = showsNavigationBar, ) } @@ -191,26 +209,36 @@ internal fun PaymentRequestsContent( requests: ImmutableList, pending: ImmutableList, contacts: ImmutableList, + subscriptions: ImmutableList, canRequestPayment: Boolean, onBack: () -> Unit, onRequestPayment: () -> Unit, onPay: (PaykitPaymentRequestId) -> Unit, - onReject: suspend (PaykitPaymentRequest) -> Result, + onDismiss: suspend (PaykitPaymentRequest) -> Result, + onDetails: (PaykitPaymentRequestId) -> Unit, + showsNavigationBar: Boolean = true, ) { val sections = paymentRequestSections(requests, pending, Clock.System.now()) Column( modifier = modifier .fillMaxSize() - .gradientBackground() - .navigationBarsPadding() + .then( + if (showsNavigationBar) { + Modifier.gradientBackground().navigationBarsPadding() + } else { + Modifier + } + ) .testTag("PaymentRequestsScreen") ) { - AppTopBar( - titleText = stringResource(R.string.wallet__payment_requests), - onBackClick = onBack, - actions = { DrawerNavIcon() }, - ) + if (showsNavigationBar) { + AppTopBar( + titleText = stringResource(R.string.wallet__payment_requests), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + } if (requests.isEmpty()) { Column( modifier = Modifier @@ -258,8 +286,10 @@ internal fun PaymentRequestsContent( request = request, isIncoming = pending.any { it.id == request.id }, contact = contacts.contactFor(request), + subscriptionNote = subscriptions.nameFor(request), onPay = onPay, - onReject = onReject, + onDismiss = onDismiss, + onDetails = onDetails, ) } } @@ -274,7 +304,11 @@ internal fun PaymentRequestsContent( PaymentRequestCard( request = request, contact = contacts.contactFor(request), - compactSubtitle = paymentRequestDate(request), + compactSubtitle = subscriptions.nameFor(request) + ?: request.note?.takeIf(String::isNotBlank) + ?: paymentRequestDate(request), + showSignedAmount = true, + onClick = { onDetails(request.id) }, ) } } @@ -306,7 +340,6 @@ private data class PaymentRequestHistorySection( private enum class PaymentRequestHistoryPeriod { Today, - Yesterday, ThisWeek, ThisMonth, ThisYear, @@ -341,21 +374,25 @@ private fun ActivePaymentRequestCard( request: PaykitPaymentRequest, isIncoming: Boolean, contact: PubkyProfile?, + subscriptionNote: String?, onPay: (PaykitPaymentRequestId) -> Unit, - onReject: suspend (PaykitPaymentRequest) -> Result, + onDismiss: suspend (PaykitPaymentRequest) -> Result, + onDetails: (PaykitPaymentRequestId) -> Unit, ) { if (isIncoming) { PaymentRequestCard( request = request, contact = contact, - compactSubtitle = paymentRequestDateTime(request), + compactSubtitle = subscriptionNote, + onClick = { onDetails(request.id) }, onPay = { onPay(request.id) }, - onReject = { onReject(request) }, + onDismiss = { onDismiss(request) }, ) } else { PaymentRequestCard( request = request, contact = contact, + onClick = { onDetails(request.id) }, compactSubtitle = stringResource( R.string.wallet__payment_request_waiting_for_recipient, contact?.name ?: PubkyProfile.placeholder(request.counterparty).name, @@ -367,7 +404,6 @@ private fun ActivePaymentRequestCard( @Composable private fun paymentRequestHistorySectionTitle(period: PaymentRequestHistoryPeriod): String = when (period) { PaymentRequestHistoryPeriod.Today -> stringResource(R.string.wallet__payment_requests_today) - PaymentRequestHistoryPeriod.Yesterday -> stringResource(R.string.wallet__payment_requests_yesterday) PaymentRequestHistoryPeriod.ThisWeek -> stringResource(R.string.wallet__payment_requests_this_week) PaymentRequestHistoryPeriod.ThisMonth -> stringResource(R.string.wallet__payment_requests_this_month) PaymentRequestHistoryPeriod.ThisYear -> stringResource(R.string.wallet__payment_requests_this_year) @@ -387,7 +423,6 @@ private fun PaykitPaymentRequest.historyPeriod( return when { date == today -> PaymentRequestHistoryPeriod.Today - date == today.minusDays(1) -> PaymentRequestHistoryPeriod.Yesterday !date.isBefore(startOfWeek) -> PaymentRequestHistoryPeriod.ThisWeek date.year == today.year && date.month == today.month -> PaymentRequestHistoryPeriod.ThisMonth date.year == today.year -> PaymentRequestHistoryPeriod.ThisYear @@ -400,16 +435,6 @@ private fun paymentRequestDate(request: PaykitPaymentRequest): String = request. uiDateText(it.epochSeconds.toULong(), UiDateStyle.DATE) } ?: paymentRequestStatus(request) -@Composable -private fun paymentRequestDateTime(request: PaykitPaymentRequest): String = request.createdAt?.let { - val timestamp = it.epochSeconds.toULong() - stringResource( - R.string.wallet__payment_request_timestamp, - uiDateText(timestamp, UiDateStyle.DATE), - uiDateText(timestamp, UiDateStyle.TIME), - ) -} ?: paymentRequestStatus(request) - @Composable private fun paymentRequestStatus(request: PaykitPaymentRequest): String { if (request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && request.isExpired(Clock.System.now())) { @@ -450,19 +475,17 @@ internal fun PaymentRequestCard( request: PaykitPaymentRequest, contact: PubkyProfile?, compactSubtitle: String? = null, + isOutgoingPayment: Boolean = false, + showSignedAmount: Boolean = false, + onClick: (() -> Unit)? = null, onPay: (() -> Unit)? = null, - onReject: (suspend () -> Result)? = null, + onDismiss: (suspend () -> Result)? = null, ) { val scope = rememberCoroutineScope() - var isRejecting by remember(request.id) { mutableStateOf(false) } + var isDismissing by remember(request.id) { mutableStateOf(false) } val displayContact = contact ?: PubkyProfile.placeholder(request.counterparty) - val subtitle = compactSubtitle ?: request.createdAt?.let { - val timestamp = it.epochSeconds.toULong() - val date = uiDateText(timestamp, UiDateStyle.DATE) - val time = uiDateText(timestamp, UiDateStyle.TIME) - val formattedTimestamp = stringResource(R.string.wallet__payment_request_timestamp, date, time) - stringResource(R.string.wallet__payment_request_contact_timestamp, displayContact.name, formattedTimestamp) - } ?: displayContact.name + val subtitle = compactSubtitle ?: request.note?.takeIf(String::isNotBlank) ?: paymentRequestDate(request) + val amountPrefix = request.amountPrefix(isOutgoingPayment, showSignedAmount) Card( colors = CardDefaults.cardColors(containerColor = Colors.Gray6), @@ -470,7 +493,7 @@ internal fun PaymentRequestCard( modifier = Modifier .fillMaxWidth() .then( - if (onPay != null || onReject != null) { + if (onPay != null || onDismiss != null) { Modifier .outerGlow( glowColor = Colors.Brand, @@ -483,17 +506,22 @@ internal fun PaymentRequestCard( Modifier } ) + .clickableAlpha(enabled = onClick != null) { onClick?.invoke() } .testTag("PaymentRequestRow${request.paymentRequestId}"), ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp), ) { - PubkyContactAvatar(profile = displayContact, size = 40.dp) + if (request.showsPaymentRailIcon(isOutgoingPayment)) { + PaymentRailIcon(request = request, paymentWasSent = request.paymentWasSent(isOutgoingPayment)) + } else { + PubkyContactAvatar(profile = displayContact, size = 40.dp) + } Column(modifier = Modifier.weight(1f)) { BodyMSB( - text = request.note ?: stringResource(R.string.wallet__payment_request), + text = displayContact.name, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -506,11 +534,12 @@ internal fun PaymentRequestCard( } MoneyCell( sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + prefix = amountPrefix, ) } - if (onPay != null || onReject != null) { + if (onPay != null || onDismiss != null) { Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxWidth() .background(Colors.Gray5) @@ -519,15 +548,15 @@ internal fun PaymentRequestCard( SecondaryButton( text = stringResource(R.string.wallet__payment_request_dismiss), onClick = { - if (isRejecting || onReject == null) return@SecondaryButton - isRejecting = true + if (isDismissing || onDismiss == null) return@SecondaryButton + isDismissing = true scope.launch { - onReject() - isRejecting = false + onDismiss() + isDismissing = false } }, - isLoading = isRejecting, - enabled = !isRejecting, + isLoading = isDismissing, + enabled = !isDismissing, icon = { Icon( painter = painterResource(R.drawable.ic_x), @@ -541,7 +570,7 @@ internal fun PaymentRequestCard( PrimaryButton( text = stringResource(R.string.wallet__payment_request_pay), onClick = { onPay?.invoke() }, - enabled = !isRejecting, + enabled = !isDismissing, icon = { Icon( painter = painterResource(R.drawable.ic_coins), @@ -557,11 +586,47 @@ internal fun PaymentRequestCard( } } +private fun PaykitPaymentRequest.amountPrefix(isOutgoingPayment: Boolean, showSignedAmount: Boolean): String = when { + isOutgoingPayment -> "-" + showSignedAmount && direction == PaykitPaymentRequestDirection.Incoming -> "-" + showSignedAmount -> "+" + else -> "" +} + private fun List.contactFor(request: PaykitPaymentRequest): PubkyProfile? = firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, request.counterparty) } +@Composable +private fun List.nameFor(request: PaykitPaymentRequest): String? { + val subscription = firstOrNull(request::belongsTo) ?: return null + return subscription.note?.takeIf(String::isNotBlank) + ?: stringResource(R.string.subscriptions__subscription) +} + private val PaykitPaymentRequest.lazyListKey: String - get() = "$paymentRequestId|$counterparty|$counterpartyReceiverPath" + get() = "$paymentRequestId|$counterparty|$counterpartyReceiverPath|${billingPeriod?.startsAt ?: ""}" + +internal val PaykitPaymentRequest.paymentRailIconColor + get() = if (paymentProofKind == PaykitPaymentProofKind.Lightning) Colors.Purple else Colors.Brand + +internal val PaykitPaymentRequest.paymentRailBackgroundColor + get() = if (paymentProofKind == PaykitPaymentProofKind.Lightning) Colors.Purple16 else Colors.Brand16 + +private fun PaykitPaymentRequest.showsPaymentRailIcon(isOutgoingPayment: Boolean): Boolean = + isOutgoingPayment || lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED + +private fun PaykitPaymentRequest.paymentWasSent(isOutgoingPayment: Boolean): Boolean = + isOutgoingPayment || direction == PaykitPaymentRequestDirection.Incoming + +@Composable +private fun PaymentRailIcon(request: PaykitPaymentRequest, paymentWasSent: Boolean) { + CircularIcon( + icon = painterResource(if (paymentWasSent) R.drawable.ic_sent else R.drawable.ic_received), + iconColor = request.paymentRailIconColor, + backgroundColor = request.paymentRailBackgroundColor, + size = 40.dp, + ) +} private val previewRequest = PaykitPaymentRequest( paymentRequestId = "payment-request", @@ -583,10 +648,12 @@ private fun PaymentRequestsSheetPreview() { PaymentRequestsSheetContent( requests = persistentListOf(previewRequest), contacts = persistentListOf(), + subscriptions = persistentListOf(), onNotNow = {}, onSeeAll = {}, onPay = {}, - onReject = { Result.success(Unit) }, + onDismiss = { Result.success(Unit) }, + onDetails = {}, ) } } @@ -606,11 +673,13 @@ private fun PaymentRequestsPreview() { ), pending = persistentListOf(previewRequest), contacts = persistentListOf(), + subscriptions = persistentListOf(), canRequestPayment = true, onBack = {}, onRequestPayment = {}, onPay = {}, - onReject = { Result.success(Unit) }, + onDismiss = { Result.success(Unit) }, + onDetails = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt new file mode 100644 index 0000000000..c265b36674 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -0,0 +1,1044 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import androidx.annotation.RawRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.rememberLottieComposition +import com.synonym.paykit.PaymentRequestLifecycleState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.ext.dateTimeFormatterOf +import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.safe +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionId +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.FillWidth +import to.bitkit.ui.components.MoneyCell +import to.bitkit.ui.components.MoneyDisplay +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SubscriptionRoute +import to.bitkit.ui.components.SwipeToConfirm +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.rememberMoneyText +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.paymentrequests.PaymentRequestCard +import to.bitkit.ui.screens.paymentrequests.PaymentRequestsScreen +import to.bitkit.ui.screens.wallets.activity.components.CustomTabRowWithSpacing +import to.bitkit.ui.screens.wallets.activity.components.TabItem +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.removeAccentTags +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AppViewModel +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Composable +fun SubscriptionsScreen( + appViewModel: AppViewModel, + onBack: () -> Unit, + onRequestPayment: () -> Unit, + onDetails: (PaykitSubscriptionId) -> Unit, + onPaymentRequestDetails: (PaykitPaymentRequestId) -> Unit, + showPayments: Boolean = false, +) { + val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val pendingPaymentRequests by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() + val now = rememberSubscriptionNow(subscriptions) + + SubscriptionsContent( + subscriptions = subscriptions.toImmutableList(), + contacts = contacts.toImmutableList(), + acceptedAt = appViewModel::subscriptionAcceptedAt, + now = now, + onBack = onBack, + initialTab = if (showPayments) SubscriptionTab.Payments else SubscriptionTab.Overview, + pendingPaymentRequestCount = pendingPaymentRequests.size, + onSubscription = { subscription -> + if (subscription.isProposalVisible(now)) { + appViewModel.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscription.id))) + } else { + onDetails(subscription.id) + } + }, + paymentsContent = { + PaymentRequestsScreen( + appViewModel = appViewModel, + onBack = onBack, + onRequestPayment = onRequestPayment, + onDetails = onPaymentRequestDetails, + showsNavigationBar = false, + ) + }, + ) +} + +@Composable +internal fun SubscriptionsContent( + subscriptions: ImmutableList, + contacts: ImmutableList, + acceptedAt: (PaykitSubscriptionId) -> Instant?, + now: Instant, + onBack: () -> Unit, + initialTab: SubscriptionTab, + pendingPaymentRequestCount: Int, + onSubscription: (PaykitSubscription) -> Unit, + paymentsContent: @Composable () -> Unit, +) { + val proposals = subscriptions.filter { it.isProposalVisible(now) } + val active = subscriptions.filter { it.isActive(now) } + val expired = subscriptions.filter { it.isExpired(now) && acceptedAt(it.id) != null } + val hasVisibleSubscriptions = proposals.isNotEmpty() || active.isNotEmpty() || expired.isNotEmpty() + var selectedTabIndex by rememberSaveable { mutableIntStateOf(initialTab.ordinal) } + val selectedTab = SubscriptionTab.entries[selectedTabIndex] + + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("SubscriptionsScreen") + ) { + AppTopBar( + titleText = stringResource(R.string.subscriptions__title), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + SubscriptionTabs( + selectedTab = selectedTab, + pendingPaymentRequestCount = pendingPaymentRequestCount, + onTabChange = { selectedTabIndex = it.ordinal }, + ) + + if (selectedTab == SubscriptionTab.Payments) { + Box(Modifier.weight(1f)) { + paymentsContent() + } + } else if (!hasVisibleSubscriptions) { + SubscriptionEmptyState(Modifier.weight(1f)) + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 32.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier.weight(1f), + ) { + item { + SubscriptionMetrics( + dueSats = dueThisMonth( + subscriptions.filter { it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING }, + acceptedAt, + now, + ), + activeCount = active.size, + ) + } + subscriptionSection( + titleRes = R.string.subscriptions__proposals, + subscriptions = proposals, + contacts = contacts, + now = now, + onSubscription = onSubscription, + ) + subscriptionSection( + titleRes = R.string.subscriptions__active, + subscriptions = active, + contacts = contacts, + now = now, + onSubscription = onSubscription, + ) + subscriptionSection( + titleRes = R.string.subscriptions__expired, + subscriptions = expired, + contacts = contacts, + now = now, + onSubscription = onSubscription, + ) + } + } + } +} + +private fun androidx.compose.foundation.lazy.LazyListScope.subscriptionSection( + @androidx.annotation.StringRes titleRes: Int, + subscriptions: List, + contacts: ImmutableList, + now: Instant, + onSubscription: (PaykitSubscription) -> Unit, +) { + if (subscriptions.isEmpty()) return + item(key = "subscription-section-$titleRes") { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Caption13Up(text = stringResource(titleRes), color = Colors.White64) + subscriptions.forEach { subscription -> + SubscriptionRow( + subscription = subscription, + contact = contacts.contactFor(subscription), + now = now, + faded = subscription.isExpired(now), + onClick = { onSubscription(subscription) }, + ) + } + } + } +} + +@Composable +private fun SubscriptionTabs( + selectedTab: SubscriptionTab, + pendingPaymentRequestCount: Int, + onTabChange: (SubscriptionTab) -> Unit, +) { + CustomTabRowWithSpacing( + tabs = persistentListOf(SubscriptionTab.Overview, SubscriptionTab.Payments), + currentTabIndex = selectedTab.ordinal, + selectedColor = Colors.White, + onTabChange = onTabChange, + badgeCount = { tab -> pendingPaymentRequestCount.takeIf { tab == SubscriptionTab.Payments } }, + modifier = Modifier.padding(horizontal = 16.dp) + ) +} + +internal enum class SubscriptionTab : TabItem { + Overview, + Payments; + + override val uiText: String + @Composable get() = stringResource( + when (this) { + Overview -> R.string.subscriptions__overview + Payments -> R.string.subscriptions__payments + } + ) +} + +@Composable +private fun SubscriptionEmptyState(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp) + ) { + FillHeight() + Image( + painter = painterResource(R.drawable.subscription_clock), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.CenterHorizontally), + ) + FillHeight() + Display( + text = stringResource(R.string.subscriptions__empty_headline).withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(12.dp) + BodyM(text = stringResource(R.string.subscriptions__empty_description), color = Colors.White64) + } +} + +@Composable +private fun SubscriptionMetrics(dueSats: Long, activeCount: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f)) { + Caption13Up(text = stringResource(R.string.subscriptions__due_this_month), color = Colors.White64) + VerticalSpacer(8.dp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painterResource(R.drawable.ic_calendar), contentDescription = null, tint = Colors.Purple) + to.bitkit.ui.components.MoneyMSB(sats = dueSats) + } + } + Spacer(Modifier.size(width = 1.dp, height = 50.dp).background(Colors.White16)) + Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) { + Caption13Up(text = stringResource(R.string.subscriptions__active), color = Colors.White64) + VerticalSpacer(8.dp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painterResource(R.drawable.ic_arrows_clockwise), contentDescription = null, tint = Colors.Purple) + BodyMSB(text = activeCount.toString()) + } + } + } +} + +@Composable +private fun SubscriptionRow( + subscription: PaykitSubscription, + contact: PubkyProfile, + now: Instant, + faded: Boolean, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .alpha(if (faded) 0.5f else 1f) + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .clickable(onClick = onClick) + .padding(16.dp), + ) { + PubkyContactAvatar(profile = contact, size = 40.dp) + Column(modifier = Modifier.padding(start = 16.dp).weight(1f)) { + BodyMSB( + text = subscription.note ?: stringResource(R.string.subscriptions__subscription), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyS( + text = subscription.rowSubtitle(now), + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MoneyCell(sats = subscription.displaySats) + } +} + +@Composable +fun SubscriptionDetailScreen( + appViewModel: AppViewModel, + id: PaykitSubscriptionId, + onBack: () -> Unit, +) { + val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val paymentHistory by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() + val subscription = subscriptions.firstOrNull { it.id == id } + val now = rememberSubscriptionNow(listOfNotNull(subscription)) + + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + ) { + AppTopBar( + titleText = subscription?.note ?: stringResource(R.string.subscriptions__subscription), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + if (subscription == null) { + FillHeight() + BodyM( + text = stringResource(R.string.subscriptions__unavailable), + color = Colors.White64, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + FillHeight() + return@Column + } + + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier + .weight(1f) + .alpha(if (subscription.isExpired(now)) 0.5f else 1f), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) { + Caption13Up(text = subscription.cadenceText(), color = Colors.White64) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + MoneyDisplay(sats = subscription.displaySats, showSymbol = true) + FillWidth() + PubkyContactAvatar(profile = contacts.contactFor(subscription), size = 48.dp) + } + } + } + item { SubscriptionDetailsGrid(subscription, now) } + val payments = paymentHistory.filter { it.belongsTo(subscription) } + if (payments.isNotEmpty()) { + item { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Caption13Up(text = stringResource(R.string.subscriptions__payments), color = Colors.White64) + payments.forEach { payment -> + PaymentRequestCard( + request = payment, + contact = contacts.contactFor(subscription), + compactSubtitle = subscription.note?.takeIf(String::isNotBlank) + ?: stringResource(R.string.subscriptions__subscription), + isOutgoingPayment = true, + ) + } + } + } + } + } + SubscriptionDetailFooter(subscription, appViewModel, now) + } +} + +@Composable +private fun SubscriptionDetailsGrid(subscription: PaykitSubscription, now: Instant) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SubscriptionDetailCell( + stringResource(R.string.subscriptions__subscription), + subscription.note ?: stringResource(R.string.subscriptions__subscription), + R.drawable.ic_cube, + Modifier.weight(1f), + ) + SubscriptionDetailCell( + stringResource(R.string.subscriptions__frequency), + subscription.frequencyValue(), + R.drawable.ic_arrows_clockwise, + Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SubscriptionDetailCell( + stringResource(R.string.subscriptions__status), + if (subscription.isActive(now)) { + stringResource(R.string.subscriptions__active) + } else { + stringResource(R.string.subscriptions__expired) + }, + R.drawable.ic_check, + Modifier.weight(1f), + ) + if (subscription.shouldShowTiming(now)) { + SubscriptionDetailCell( + subscription.timingTitle(now), + subscription.renewalText(now), + R.drawable.ic_calendar, + Modifier.weight(1f), + ) + } else { + Spacer(Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun SubscriptionDetailCell( + title: String, + value: String, + @androidx.annotation.DrawableRes iconRes: Int, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .height(68.dp) + ) { + Caption13Up(text = title, color = Colors.White64) + VerticalSpacer(8.dp) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = Colors.Purple, + modifier = Modifier.size(16.dp), + ) + BodySSB(text = value, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + FillHeight() + HorizontalDivider(color = Colors.White10) + } +} + +@Composable +private fun SubscriptionDetailFooter( + subscription: PaykitSubscription, + appViewModel: AppViewModel, + now: Instant, +) { + val hasMoreInfo = subscription.metadata.description != null || subscription.metadata.benefits.isNotEmpty() + val canCancel = subscription.canCancel(now) + if (!hasMoreInfo && !canCancel) return + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + ) { + if (hasMoreInfo) { + SecondaryButton( + text = stringResource(R.string.subscriptions__more_info), + onClick = { appViewModel.showSheet(Sheet.Subscription(SubscriptionRoute.Details(subscription.id))) }, + modifier = Modifier.weight(1f), + ) + } + if (canCancel) { + PrimaryButton( + text = stringResource(R.string.subscriptions__cancel), + onClick = { appViewModel.showSheet(Sheet.Subscription(SubscriptionRoute.Cancel(subscription.id))) }, + modifier = Modifier.weight(1f), + icon = { + Icon( + painter = painterResource(R.drawable.ic_x), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } +} + +@Composable +fun SubscriptionSheet(appViewModel: AppViewModel, initialRoute: SubscriptionRoute) { + var route by remember(initialRoute) { mutableStateOf(initialRoute) } + var previousRoute by remember(initialRoute) { mutableStateOf(null) } + var isProcessing by remember(initialRoute) { mutableStateOf(false) } + val subscriptions by appViewModel.subscriptions.collectAsStateWithLifecycle() + val isAccepting by appViewModel.isAcceptingSubscription.collectAsStateWithLifecycle() + val subscription = subscriptions.firstOrNull { it.id == route.id } + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val now = rememberSubscriptionNow(listOfNotNull(subscription)) + + LaunchedEffect(route, subscription, isProcessing) { + val proposalIsUnavailable = route is SubscriptionRoute.Review && + subscription?.isProposalVisible(now) != true + if (!isProcessing && proposalIsUnavailable) { + appViewModel.hideSheet() + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .sheetHeight() + .gradientBackground() + ) { + if (subscription == null) { + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__subscription)) + FillHeight() + BodyM(stringResource(R.string.subscriptions__unavailable), color = Colors.White64) + FillHeight() + PrimaryButton(text = stringResource(R.string.common__close), onClick = appViewModel::hideSheet) + VerticalSpacer(16.dp) + } + } else { + val payOnAcceptance = subscription.paymentDueOnAcceptance(now) != null + when (route) { + is SubscriptionRoute.Review -> SubscriptionReview( + subscription = subscription, + payOnAcceptance = payOnAcceptance, + now = now, + contact = contacts.contactFor(subscription), + onDetails = { + if (!isAccepting) { + previousRoute = route + route = SubscriptionRoute.Details(subscription.id) + } + }, + onSubscribe = { + isProcessing = true + appViewModel.acceptSubscriptionAndStartPayment(subscription).fold( + onSuccess = { startedPayment -> + if (startedPayment) { + true + } else { + route = SubscriptionRoute.Success(subscription.id) + isProcessing = false + true + } + }, + onFailure = { + isProcessing = false + false + }, + ) + }, + ) + is SubscriptionRoute.Success -> SubscriptionSuccess( + onClose = appViewModel::hideSheet, + paymentType = null, + ) + is SubscriptionRoute.Details -> SubscriptionMoreInfo( + subscription = subscription, + contact = contacts.contactFor(subscription), + onBack = { + if (previousRoute != null) { + route = requireNotNull(previousRoute) + previousRoute = null + } else { + appViewModel.hideSheet() + } + }, + onClose = appViewModel::hideSheet, + ) + is SubscriptionRoute.Cancel -> SubscriptionCancel( + subscription = subscription, + contact = contacts.contactFor(subscription), + onDetails = { + previousRoute = route + route = SubscriptionRoute.Details(subscription.id) + }, + onCancel = { + appViewModel.cancelSubscription(subscription.id) + .onSuccess { appViewModel.hideSheet() } + .isSuccess + }, + ) + } + } + } +} + +private val SubscriptionRoute.id: PaykitSubscriptionId + get() = when (this) { + is SubscriptionRoute.Review -> id + is SubscriptionRoute.Success -> id + is SubscriptionRoute.Details -> id + is SubscriptionRoute.Cancel -> id + } + +@Composable +private fun SubscriptionReview( + subscription: PaykitSubscription, + contact: PubkyProfile, + payOnAcceptance: Boolean, + now: Instant, + onDetails: () -> Unit, + onSubscribe: suspend () -> Boolean, +) { + var loading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + ) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__review_and_subscribe)) + rememberMoneyText(sats = subscription.displaySats, reversed = true, showSymbol = true)?.let { + Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + } + MoneyDisplay(sats = subscription.displaySats, showSymbol = true) + VerticalSpacer(24.dp) + SubscriptionProviderCard(subscription, contact, onClick = onDetails) + if (!subscription.recurrence.unit.isSupported) { + VerticalSpacer(16.dp) + BodyM(text = stringResource(R.string.subscriptions__unsupported_description), color = Colors.White64) + } else if (subscription.acceptedPaymentEndpointIdentifiers.isEmpty()) { + VerticalSpacer(16.dp) + BodyM( + text = stringResource(R.string.subscriptions__unsupported_payment_description), + color = Colors.White64, + ) + } + FillHeight() + Image( + painter = painterResource(R.drawable.subscription_clock), + contentDescription = null, + modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally), + ) + FillHeight() + if (subscription.isProposalActionable(now)) { + SwipeToConfirm( + text = stringResource( + if (payOnAcceptance) { + R.string.subscriptions__swipe_to_subscribe_and_pay + } else { + R.string.subscriptions__swipe_to_subscribe + } + ), + color = Colors.Purple, + loading = loading, + onConfirm = { + loading = true + scope.launch { + if (!onSubscribe()) loading = false + } + }, + ) + } + VerticalSpacer(16.dp) + } +} + +@Composable +private fun SubscriptionProviderCard( + subscription: PaykitSubscription, + contact: PubkyProfile, + subtitle: String? = null, + onClick: (() -> Unit)? = null, +) { + val displayedSubtitle = subtitle ?: subscription.subscriptionFrequencyText() + val cardModifier = if (onClick == null) { + Modifier.fillMaxWidth() + } else { + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .clickable(onClick = onClick) + .padding(16.dp) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = cardModifier, + ) { + PubkyContactAvatar(profile = contact, size = 40.dp) + Column(Modifier.padding(start = 16.dp).weight(1f)) { + BodyMSB( + text = subscription.note ?: stringResource(R.string.subscriptions__subscription), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyS( + text = displayedSubtitle, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (onClick != null) { + Icon(painterResource(R.drawable.ic_chevron_right), contentDescription = null, tint = Colors.White64) + } + } +} + +@Composable +fun SubscriptionSuccess( + onClose: () -> Unit, + paymentType: NewTransactionSheetType?, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + val composition by rememberLottieComposition( + LottieCompositionSpec.RawRes(subscriptionConfettiResource(paymentType)) + ) + LottieAnimation( + composition = composition, + contentScale = ContentScale.Crop, + iterations = 100, + modifier = Modifier.fillMaxSize(), + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__subscribed)) + FillHeight() + Image( + painter = painterResource(R.drawable.check), + contentDescription = null, + modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally), + ) + FillHeight() + PrimaryButton(text = stringResource(R.string.common__close), onClick = onClose) + VerticalSpacer(16.dp) + } + } +} + +@RawRes +internal fun subscriptionConfettiResource(paymentType: NewTransactionSheetType?): Int = + if (paymentType == NewTransactionSheetType.ONCHAIN) R.raw.confetti_orange else R.raw.confetti_purple + +@Composable +private fun SubscriptionMoreInfo( + subscription: PaykitSubscription, + contact: PubkyProfile, + onBack: () -> Unit, + onClose: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + ) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__details), onBack = onBack) + SubscriptionProviderCard(subscription, contact) + VerticalSpacer(24.dp) + LazyColumn(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.weight(1f)) { + subscription.metadata.description?.let { item { BodySSB(it) } } + items(subscription.metadata.benefits) { benefit -> + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + BodySSB("•") + BodySSB(benefit) + } + } + } + PrimaryButton(text = stringResource(R.string.common__ok), onClick = onClose) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun SubscriptionCancel( + subscription: PaykitSubscription, + contact: PubkyProfile, + onDetails: () -> Unit, + onCancel: suspend () -> Boolean, +) { + var loading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + ) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__cancel_subscription)) + rememberMoneyText(sats = subscription.displaySats, reversed = true, showSymbol = true)?.let { + Caption13Up(text = it.removeAccentTags(), color = Colors.White64) + } + MoneyDisplay(sats = subscription.displaySats, showSymbol = true) + VerticalSpacer(24.dp) + SubscriptionProviderCard( + subscription = subscription, + contact = contact, + subtitle = subscription.rowSubtitle(Clock.System.now()), + onClick = onDetails, + ) + FillHeight() + Image( + painter = painterResource(R.drawable.cross), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.CenterHorizontally), + ) + FillHeight() + SwipeToConfirm( + text = stringResource(R.string.subscriptions__swipe_to_cancel), + color = Colors.Red, + loading = loading, + onConfirm = { + loading = true + scope.launch { + if (!onCancel()) loading = false + } + }, + ) + VerticalSpacer(16.dp) + } +} + +private fun List.contactFor(subscription: PaykitSubscription): PubkyProfile = + firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, subscription.counterparty) } + ?: PubkyProfile.placeholder(subscription.counterparty) + +@Composable +private fun PaykitSubscription.cadenceText(): String = when (recurrence.unit) { + PaykitRecurrenceUnit.Day -> if (recurrence.every == 1) { + stringResource(R.string.subscriptions__per_day) + } else { + stringResource(R.string.subscriptions__every_days, recurrence.every) + } + PaykitRecurrenceUnit.Week -> if (recurrence.every == 1) { + stringResource(R.string.subscriptions__per_week) + } else { + stringResource(R.string.subscriptions__every_weeks, recurrence.every) + } + PaykitRecurrenceUnit.Month -> if (recurrence.every == 1) { + stringResource(R.string.subscriptions__per_month) + } else { + stringResource(R.string.subscriptions__every_months, recurrence.every) + } + PaykitRecurrenceUnit.Year -> if (recurrence.every == 1) { + stringResource(R.string.subscriptions__per_year) + } else { + stringResource(R.string.subscriptions__every_years, recurrence.every) + } + PaykitRecurrenceUnit.Minute, PaykitRecurrenceUnit.Hour -> + stringResource(R.string.subscriptions__unsupported_frequency) +} + +@Composable +private fun PaykitSubscription.frequencyValue(): String { + if (recurrence.every != 1) return cadenceText() + return when (recurrence.unit) { + PaykitRecurrenceUnit.Day -> stringResource(R.string.subscriptions__daily) + PaykitRecurrenceUnit.Week -> stringResource(R.string.subscriptions__weekly) + PaykitRecurrenceUnit.Month -> stringResource(R.string.subscriptions__monthly) + PaykitRecurrenceUnit.Year -> stringResource(R.string.subscriptions__yearly) + PaykitRecurrenceUnit.Minute, PaykitRecurrenceUnit.Hour -> + stringResource(R.string.subscriptions__unsupported_frequency) + } +} + +@Composable +private fun PaykitSubscription.subscriptionFrequencyText(): String { + if (recurrence.every != 1) return cadenceText() + return when (recurrence.unit) { + PaykitRecurrenceUnit.Day -> stringResource(R.string.subscriptions__daily_subscription) + PaykitRecurrenceUnit.Week -> stringResource(R.string.subscriptions__weekly_subscription) + PaykitRecurrenceUnit.Month -> stringResource(R.string.subscriptions__monthly_subscription) + PaykitRecurrenceUnit.Year -> stringResource(R.string.subscriptions__yearly_subscription) + PaykitRecurrenceUnit.Minute, PaykitRecurrenceUnit.Hour -> + stringResource(R.string.subscriptions__unsupported_frequency) + } +} + +@Composable +private fun PaykitSubscription.rowSubtitle(now: Instant): String = when { + isProposalVisible(now) || !recurrence.unit.isSupported -> subscriptionFrequencyText() + isExpired(now) -> recurrence.endsAt?.let { + stringResource(R.string.subscriptions__expires_date, it.formatShortDate()) + } ?: stringResource(R.string.subscriptions__expired) + recurrence.endsAt != null -> stringResource( + R.string.subscriptions__expires_date, + recurrence.endsAt.formatShortDate(), + ) + else -> { + val renewal = recurrence.nextPeriodAfter(now)?.startsAt + if (renewal == null) { + subscriptionFrequencyText() + } else { + stringResource( + R.string.subscriptions__renews_date, + renewal.formatShortDate(), + ) + } + } +} + +internal fun PaykitSubscription.shouldShowTiming(now: Instant): Boolean = + isActive(now) || recurrence.endsAt != null + +internal fun PaykitSubscription.canCancel(now: Instant): Boolean = + isActive(now) && recurrence.endsAt == null + +@Composable +private fun PaykitSubscription.timingTitle(now: Instant): String = when { + !isActive(now) -> stringResource(R.string.subscriptions__expired) + recurrence.endsAt == null -> stringResource(R.string.subscriptions__renews) + else -> stringResource(R.string.subscriptions__expires) +} + +@Composable +private fun PaykitSubscription.renewalText(now: Instant): String = + (recurrence.endsAt ?: recurrence.nextPeriodAfter(now)?.startsAt)?.formatFullDate() + ?: stringResource(R.string.subscriptions__ongoing) + +@Composable +private fun rememberSubscriptionNow(subscriptions: List): Instant { + var now by remember(subscriptions) { mutableStateOf(Clock.System.now()) } + LaunchedEffect(subscriptions, now) { + val nextTransition = nextSubscriptionTransition(subscriptions, now) ?: return@LaunchedEffect + delay(nextTransition - now) + now = Clock.System.now() + } + return now +} + +internal fun nextSubscriptionTransition( + subscriptions: List, + now: Instant, + zoneId: java.time.ZoneId = java.time.ZoneId.systemDefault(), +): Instant? { + val activeSubscriptions = subscriptions.filter { it.isActive(now) } + val dates = subscriptions.flatMap { + listOf(it.recurrence.startsAt, it.proposalExpiresAt, it.recurrence.endsAt) + }.filterNotNull().toMutableList() + dates += activeSubscriptions.mapNotNull { it.recurrence.nextPeriodAfter(now)?.startsAt } + if (activeSubscriptions.isNotEmpty()) { + val nextMonth = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()) + .atZone(zoneId) + .toLocalDate() + .withDayOfMonth(1) + .plusMonths(1) + .atStartOfDay(zoneId) + .toInstant() + dates += Instant.fromEpochMilliseconds(nextMonth.toEpochMilli()) + } + return dates.filter { it > now }.minOrNull() +} + +private fun Instant.formatShortDate(): String = dateTimeFormatterOf("MMMM d") + .format(java.time.Instant.ofEpochMilli(toEpochMilliseconds())) + +private fun Instant.formatFullDate(): String = dateTimeFormatterOf("MMMM d, yyyy") + .format(java.time.Instant.ofEpochMilli(toEpochMilliseconds())) + +private val PaykitSubscription.displaySats: Long + get() = amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() + +private fun dueThisMonth( + subscriptions: List, + acceptedAt: (PaykitSubscriptionId) -> Instant?, + now: Instant, +): Long { + val zonedNow = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(java.time.ZoneId.systemDefault()) + val start = zonedNow.withDayOfMonth(1).toLocalDate().atStartOfDay(zonedNow.zone).toInstant() + val end = zonedNow.plusMonths(1).withDayOfMonth(1).toLocalDate().atStartOfDay(zonedNow.zone).toInstant() + val startInstant = Instant.fromEpochMilliseconds(start.toEpochMilli()) + val endInstant = Instant.fromEpochMilliseconds(end.toEpochMilli()) + val total = subscriptions.fold(0uL) { total, subscription -> + val acceptance = acceptedAt(subscription.id) ?: return@fold total + val count = subscription.recurrence.periodsThrough(endInstant, acceptance).count { + it.startsAt >= startInstant && it.startsAt < endInstant && it !in subscription.paidPeriods + } + val subtotal = subscription.amountSats.safe() * count.toULong().safe() + total.safe() + subtotal.safe() + } + return total.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt index 4c7e2fccfe..4a03f80e3d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/CustomTabRowWithSpacing.kt @@ -12,7 +12,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -33,6 +35,7 @@ fun CustomTabRowWithSpacing( onTabChange: (T) -> Unit, modifier: Modifier = Modifier, selectedColor: Color = Colors.Brand, + badgeCount: (T) -> Int? = { null }, ) { Column(modifier = modifier) { Row( @@ -54,12 +57,27 @@ fun CustomTabRowWithSpacing( .padding(vertical = 8.dp) .testTag("Tab-${tab.name.lowercase()}") ) { - CaptionB( - tab.uiText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isSelected) Colors.White else Colors.White50 - ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CaptionB( + tab.uiText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) Colors.White else Colors.White50 + ) + badgeCount(tab)?.takeIf { it > 0 }?.let { count -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(20.dp) + .background(Colors.Brand, CircleShape), + ) { + CaptionB(text = count.toString(), color = Colors.White) + } + } + } } val animatedColor by animateColorAsState( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 289a6f2587..88f7b569e8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -93,6 +94,8 @@ fun ReceiveQrScreen( onClickReceiveCjit: () -> Unit, modifier: Modifier = Modifier, initialTab: ReceiveTab? = null, + showPaymentRequestContacts: Boolean = false, + onClickPaymentRequestContacts: () -> Unit = {}, ) { SetMaxBrightness() @@ -197,7 +200,26 @@ fun ReceiveQrScreen( .navigationBarsPadding() .keepScreenOn() ) { - SheetTopBar(stringResource(R.string.wallet__receive_bitcoin)) + SheetTopBar( + titleText = stringResource(R.string.wallet__receive_bitcoin), + action = if (showPaymentRequestContacts) { + { + IconButton( + onClick = onClickPaymentRequestContacts, + modifier = Modifier.testTag("ReceivePaymentRequestContacts"), + ) { + Icon( + painter = painterResource(R.drawable.ic_users), + contentDescription = stringResource(R.string.wallet__payment_request_choose_recipient), + tint = Colors.White, + modifier = Modifier.size(24.dp), + ) + } + } + } else { + null + }, + ) Column { VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index dc7587a4c7..c2dbb5b3e1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -22,15 +22,19 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute import kotlinx.serialization.Serializable import to.bitkit.R +import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.LightningState import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDraft +import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.WalletState import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.navigateTo import to.bitkit.ui.openNotificationSettings +import to.bitkit.ui.screens.paymentrequests.PaymentRequestAmountScreen import to.bitkit.ui.screens.paymentrequests.PaymentRequestDetailsScreen import to.bitkit.ui.screens.paymentrequests.PaymentRequestRecipientScreen import to.bitkit.ui.screens.paymentrequests.PaymentRequestSentScreen @@ -48,6 +52,7 @@ import kotlin.time.Duration.Companion.days import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) +@Suppress("CyclomaticComplexMethod") @Composable fun ReceiveSheet( appViewModel: AppViewModel, @@ -69,6 +74,7 @@ fun ReceiveSheet( val cjitEntryDetails = remember { mutableStateOf(null) } val lightningState: LightningState by wallet.lightningState.collectAsStateWithLifecycle() val paymentRequestTargets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() + val paymentRequestContacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() var paymentRequestDraft by remember { mutableStateOf( PaykitPaymentRequestDraft( @@ -79,6 +85,17 @@ fun ReceiveSheet( ) } var createdPaymentRequest by remember { mutableStateOf(null) } + var selectedPaymentRequestTarget by remember(startRoute) { + mutableStateOf( + (startRoute as? ReceiveRoute.PaymentRequestAmount)?.let { + val publicKey = it.publicKey ?: return@let null + val receiverPath = it.receiverPath ?: return@let null + PaykitPaymentRequestTarget(publicKey, receiverPath) + } + ) + } + var skipPaymentRequestAmount by remember { mutableStateOf(false) } + var isEditingPaymentRequestAmount by remember { mutableStateOf(false) } LaunchedEffect(Unit) { wallet.resetPreActivityMetadataTagsForCurrentInvoice() @@ -118,43 +135,91 @@ fun ReceiveSheet( } }, onClickEditInvoice = { navController.navigateTo(ReceiveRoute.EditInvoice) }, - ) - } - composableWithDefaultTransitions { - PaymentRequestDetailsScreen( - amountInputViewModel = paymentRequestAmountViewModel, - initialDraft = paymentRequestDraft, - onBack = { navController.popBackStack() }, - onContinue = { - paymentRequestDraft = it + showPaymentRequestContacts = paymentRequestTargets.isNotEmpty(), + onClickPaymentRequestContacts = { + paymentRequestDraft = paymentRequestDraft.copy( + amountSats = 0uL, + note = "", + expiresAt = Clock.System.now() + 7.days, + ) + selectedPaymentRequestTarget = null + skipPaymentRequestAmount = false + isEditingPaymentRequestAmount = false navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) }, ) } - composableWithDefaultTransitions { - PaymentRequestDetailsScreen( + composableWithDefaultTransitions { backStackEntry -> + val route = backStackEntry.toRoute() + val routeTarget = route.publicKey?.let { publicKey -> + route.receiverPath?.let { receiverPath -> PaykitPaymentRequestTarget(publicKey, receiverPath) } + } + val contact = (routeTarget ?: selectedPaymentRequestTarget)?.let { target -> + paymentRequestContacts.firstOrNull { + PubkyPublicKeyFormat.matches(it.publicKey, target.publicKey) + } + } + PaymentRequestAmountScreen( amountInputViewModel = paymentRequestAmountViewModel, initialDraft = paymentRequestDraft, - onBack = { navController.popBackStack() }, + contact = contact, + onBack = { + isEditingPaymentRequestAmount = false + if (!navController.popBackStack()) appViewModel.hideSheet() + }, onContinue = { paymentRequestDraft = it - navController.popBackStack() + if (isEditingPaymentRequestAmount) { + isEditingPaymentRequestAmount = false + navController.popBackStack() + } else { + navController.navigateTo(ReceiveRoute.PaymentRequestDetails) + } }, ) } composableWithDefaultTransitions { PaymentRequestRecipientScreen( appViewModel = appViewModel, - draft = paymentRequestDraft, - onEditExpiration = { - navController.navigateTo(ReceiveRoute.PaymentRequestExpiration) + onBack = { + if (!navController.popBackStack()) appViewModel.hideSheet() }, - onSent = { - createdPaymentRequest = it - navController.navigateTo(ReceiveRoute.PaymentRequestSent) + onSelected = { target -> + selectedPaymentRequestTarget = target + navController.navigateTo( + if (skipPaymentRequestAmount) { + ReceiveRoute.PaymentRequestDetails + } else { + ReceiveRoute.PaymentRequestAmount() + } + ) }, ) } + composableWithDefaultTransitions { + val target = selectedPaymentRequestTarget + if (target != null) { + PaymentRequestDetailsScreen( + appViewModel = appViewModel, + draft = paymentRequestDraft, + target = target, + onBack = { navController.popBackStack() }, + onEditAmount = { + paymentRequestDraft = it + isEditingPaymentRequestAmount = true + navController.navigateTo(ReceiveRoute.PaymentRequestAmount()) + }, + onSent = { + createdPaymentRequest = it + navController.navigateTo(ReceiveRoute.PaymentRequestSent) + }, + ) + } else { + LaunchedEffect(Unit) { + if (!navController.popBackStack()) appViewModel.hideSheet() + } + } + } composableWithDefaultTransitions { createdPaymentRequest?.let { PaymentRequestSentScreen( @@ -267,6 +332,9 @@ fun ReceiveSheet( note = note, expiresAt = Clock.System.now() + 7.days, ) + selectedPaymentRequestTarget = null + skipPaymentRequestAmount = true + isEditingPaymentRequestAmount = false navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) }, navigateReceiveConfirm = { entry -> @@ -331,13 +399,16 @@ sealed interface ReceiveRoute { data object AddTag : DeepLinkStart @Serializable - data object PaymentRequestDetails : InternalOnly + data object PaymentRequestRecipient : InternalOnly @Serializable - data object PaymentRequestExpiration : InternalOnly + data class PaymentRequestAmount( + val publicKey: String? = null, + val receiverPath: String? = null, + ) : InternalOnly @Serializable - data object PaymentRequestRecipient : InternalOnly + data object PaymentRequestDetails : InternalOnly @Serializable data object PaymentRequestSent : InternalOnly diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 5d0929bdce..16a9d517ba 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -34,11 +34,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag @@ -65,6 +60,7 @@ import to.bitkit.ext.formatInvoiceExpiryRelative import to.bitkit.models.FeeRate import to.bitkit.models.PubkyProfile import to.bitkit.models.TransactionSpeed +import to.bitkit.ui.components.AddTagButton import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodySSB @@ -72,6 +68,7 @@ import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactAvatar @@ -87,7 +84,6 @@ import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground -import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.rememberBiometricAuthSupported @@ -110,6 +106,7 @@ fun SendConfirmScreen( savedStateHandle: SavedStateHandle, uiState: SendUiState, isNodeRunning: Boolean, + canAutoStart: Boolean, canGoBack: Boolean, onBack: () -> Unit, onEvent: (SendEvent) -> Unit, @@ -135,6 +132,9 @@ fun SendConfirmScreen( .collect { isSuccess -> isLoading = isSuccess savedStateHandle.remove(PIN_CHECK_RESULT_KEY) + if (!isSuccess && uiState.isInitialSubscriptionPayment) { + currentOnEvent(SendEvent.CancelInitialSubscriptionPayment) + } } } @@ -153,6 +153,12 @@ fun SendConfirmScreen( } } + LaunchedEffect(uiState.initialSubscriptionPaymentAutoStartPending, canAutoStart) { + if (!uiState.initialSubscriptionPaymentAutoStartPending || !canAutoStart) return@LaunchedEffect + isLoading = true + currentOnEvent(SendEvent.StartInitialSubscriptionPayment) + } + Content( uiState = uiState, isNodeRunning = isNodeRunning, @@ -211,6 +217,8 @@ private fun Content( SendContactTopBar( titleText = when { + uiState.isInitialSubscriptionPayment -> stringResource(R.string.subscriptions__review_and_subscribe) + uiState.isSubscriptionPayment -> stringResource(R.string.subscriptions__subscription) uiState.isPaymentRequest -> stringResource(R.string.wallet__payment_request) isLnurlPay -> stringResource(R.string.wallet__lnurl_p_title) else -> stringResource(R.string.wallet__send_review) @@ -221,7 +229,11 @@ private fun Content( Spacer(Modifier.height(16.dp)) - if (isNodeRunning) { + if (uiState.isInitialSubscriptionPayment) { + FillHeight() + GradientCircularProgressIndicator(modifier = Modifier.size(32.dp).align(Alignment.CenterHorizontally)) + FillHeight() + } else if (isNodeRunning) { ContentRunning( uiState = uiState, isLoading = isLoading, @@ -257,7 +269,11 @@ private fun Content( onConfirm = { onEvent(SendEvent.ConfirmAmountWarning(dialog)) }, onDismiss = { onEvent(SendEvent.DismissAmountWarning) - onBack() + if (uiState.isInitialSubscriptionPayment) { + onEvent(SendEvent.CancelInitialSubscriptionPayment) + } else { + onBack() + } }, modifier = Modifier .semantics { testTagsAsResourceId = true } @@ -377,7 +393,13 @@ private fun ContentRunning( } SwipeToConfirm( - text = stringResource(R.string.wallet__send_swipe), + text = stringResource( + if (uiState.isInitialSubscriptionPayment) { + R.string.subscriptions__swipe_to_subscribe_and_pay + } else { + R.string.wallet__send_swipe + } + ), color = accentColor, loading = isLoading, confirmed = isLoading, @@ -439,44 +461,6 @@ private fun TagsSection( } } -@Composable -private fun AddTagButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val shape = AppShapes.small - val cornerRadius = 8.dp - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = modifier - .clip(shape) - .drawBehind { - drawRoundRect( - color = Colors.White64, - style = Stroke( - width = 1.dp.toPx(), - pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 4f)), - ), - cornerRadius = CornerRadius(cornerRadius.toPx()), - ) - } - .clickableAlpha(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 8.dp) - ) { - BodySSB( - text = stringResource(R.string.wallet__tags_add_button), - color = Colors.White, - ) - Icon( - painter = painterResource(R.drawable.ic_plus), - contentDescription = null, - tint = Colors.White64, - modifier = Modifier.size(16.dp) - ) - } -} - @Composable private fun OnChainDetails( uiState: SendUiState, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt index f574bfbd4b..1032cea971 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt @@ -32,6 +32,8 @@ fun SendErrorScreen( title: String, message: String?, isRetrying: Boolean, + retryText: String? = null, + secondaryText: String? = null, onRetry: () -> Unit, onContactSupport: () -> Unit, ) { @@ -39,6 +41,8 @@ fun SendErrorScreen( title = title, message, isRetrying = isRetrying, + retryText = retryText, + secondaryText = secondaryText, onRetry = onRetry, onContactSupport = onContactSupport, ) @@ -50,6 +54,8 @@ private fun Content( message: String?, modifier: Modifier = Modifier, isRetrying: Boolean = false, + retryText: String? = null, + secondaryText: String? = null, onRetry: () -> Unit = {}, onContactSupport: () -> Unit = {}, ) { @@ -84,7 +90,7 @@ private fun Content( FillHeight() SecondaryButton( - text = stringResource(R.string.wallet__send_error_support), + text = secondaryText ?: stringResource(R.string.wallet__send_error_support), onClick = onContactSupport, enabled = !isRetrying, modifier = Modifier @@ -95,7 +101,7 @@ private fun Content( VerticalSpacer(16.dp) PrimaryButton( - text = stringResource(R.string.common__try_again), + text = retryText ?: stringResource(R.string.common__try_again), onClick = onRetry, isLoading = isRetrying, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index 24b631d3ec..acc17b014f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -45,6 +45,7 @@ import to.bitkit.ui.theme.Colors fun SendPendingScreen( paymentHash: String, amount: Long, + observeResolution: Boolean = true, onPaymentSuccess: (String) -> Unit, onPaymentError: (PendingPaymentResolution.Failure) -> Unit, onClose: () -> Unit, @@ -53,9 +54,11 @@ fun SendPendingScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { viewModel.init(paymentHash, amount) } + if (observeResolution) { + LaunchedEffect(Unit) { viewModel.init(paymentHash, amount) } + } - uiState.resolution?.let { resolution -> + uiState.resolution?.takeIf { observeResolution }?.let { resolution -> LaunchedEffect(resolution) { when (resolution) { is PendingPaymentResolution.Success -> onPaymentSuccess(resolution.paymentHash) @@ -66,7 +69,7 @@ fun SendPendingScreen( } Content( - amount = uiState.amount, + amount = if (observeResolution) uiState.amount else amount, activityId = uiState.activityId, onClose = onClose, onViewDetails = onViewDetails, 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 c5d3c95d05..89c1a0b7d9 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -41,6 +41,7 @@ import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.SyncNodeView import to.bitkit.ui.navigateTo import to.bitkit.ui.screens.scanner.QrScanningScreen +import to.bitkit.ui.screens.subscriptions.SubscriptionSuccess import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.screens.wallets.send.PIN_CHECK_RESULT_KEY import to.bitkit.ui.screens.wallets.send.SendAddressScreen @@ -141,7 +142,7 @@ fun SendSheet( is SendEffect.NavigateToComingSoon -> navController.navigateTo(SendRoute.ComingSoon) is SendEffect.NavigateToContacts -> navController.navigateTo(SendRoute.ContactSelect) is SendEffect.NavigateToPending -> navController.navigateTo( - SendRoute.Pending(it.paymentHash, it.amount) + SendRoute.Pending(it.paymentHash, it.amount, observeResolution = it.observeResolution) ) { popUpTo(startDestination) { inclusive = true } } is SendEffect.NavigateToError -> navController.navigateTo( SendRoute.errorFromFailure( @@ -249,6 +250,7 @@ fun SendSheet( savedStateHandle = it.savedStateHandle, uiState = uiState, isNodeRunning = lightningState.nodeLifecycleState.isRunning(), + canAutoStart = !isOffline && !shouldShowSyncOverlay, canGoBack = startDestination != SendRoute.Confirm, onBack = { val didPopToAmount = navController.popBackStack(SendRoute.Amount, inclusive = false) @@ -263,17 +265,26 @@ fun SendSheet( ) } composableWithDefaultTransitions { + val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() val sendDetail by appViewModel.successSendUiState.collectAsStateWithLifecycle() - NewTransactionSheetView( - details = sendDetail, - onCloseClick = { appViewModel.hideSheet() }, - onDetailClick = { appViewModel.onClickSendDetail() }, - modifier = Modifier - .fillMaxSize() - .gradientBackground() - .navigationBarsPadding() - .testTag("SendSuccess") - ) + if (sendUiState.isInitialSubscriptionPayment) { + SubscriptionSuccess( + onClose = appViewModel::hideSheet, + paymentType = sendDetail.type, + modifier = Modifier.gradientBackground(), + ) + } else { + NewTransactionSheetView( + details = sendDetail, + onCloseClick = { appViewModel.hideSheet() }, + onDetailClick = { appViewModel.onClickSendDetail() }, + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("SendSuccess") + ) + } } composableWithDefaultTransitions { val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() @@ -368,6 +379,7 @@ fun SendSheet( SendPendingScreen( paymentHash = route.paymentHash, amount = route.amount, + observeResolution = route.observeResolution, onPaymentSuccess = { paymentHash -> appViewModel.onSendSuccess( NewTransactionSheetDetails( @@ -406,12 +418,30 @@ fun SendSheet( val route = it.toRoute() val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() val isRetrying by walletViewModel.isRetryingLightningPayment.collectAsStateWithLifecycle() + val isRetryingInitialSubscriptionPayment by + appViewModel.isRetryingInitialSubscriptionPayment.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() SendErrorScreen( - title = stringResource(route.failureTitle(sendUiState.payMethod)), - message = route.message, - isRetrying = isRetrying, + title = if (sendUiState.isInitialSubscriptionPayment) { + stringResource(R.string.subscriptions__first_payment_failed) + } else { + stringResource(route.failureTitle(sendUiState.payMethod)) + }, + message = if (sendUiState.isInitialSubscriptionPayment) { + stringResource(R.string.subscriptions__first_payment_failed_description) + } else { + route.message + }, + isRetrying = isRetrying || isRetryingInitialSubscriptionPayment, + retryText = stringResource(R.string.subscriptions__retry_payment) + .takeIf { sendUiState.isInitialSubscriptionPayment }, + secondaryText = stringResource(R.string.wallet__payment_requests_not_now) + .takeIf { sendUiState.isInitialSubscriptionPayment }, onRetry = { + sendUiState.incomingPaymentRequestId?.let { + appViewModel.retryIncomingPaymentRequest(it) + return@SendErrorScreen + } if (isRetrying) return@SendErrorScreen scope.launch { val shouldResetRoutingCaches = route.shouldResetRoutingCaches( @@ -435,12 +465,16 @@ fun SendSheet( } }, onContactSupport = { - appViewModel.navigateToReportIssue( - route.supportMessage( - paymentMethod = route.supportPaymentMethod(sendUiState.payMethod), - routingCacheResetAttempted = routingCacheResetAttempted, + if (sendUiState.isInitialSubscriptionPayment) { + appViewModel.hideSheet() + } else { + appViewModel.navigateToReportIssue( + route.supportMessage( + paymentMethod = route.supportPaymentMethod(sendUiState.payMethod), + routingCacheResetAttempted = routingCacheResetAttempted, + ) ) - ) + } }, ) } @@ -533,6 +567,7 @@ sealed interface SendRoute { data class Pending( val paymentHash: String, val amount: Long, + val observeResolution: Boolean = true, val retryRoute: SendRetryRoute = SendRetryRoute.Confirm, val paymentRequest: String? = null, ) : InternalOnly diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0b234827f0..719e6ad6bf 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -24,6 +24,7 @@ import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.SortDirection import com.synonym.bitkitcore.validateBitcoinAddress +import com.synonym.paykit.PaymentRequestLifecycleState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList @@ -54,6 +55,7 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -67,6 +69,7 @@ import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.SpendableUtxo @@ -143,6 +146,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.PaykitOnchainPaymentProofResolution import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PaykitPaymentRequest @@ -152,6 +156,8 @@ import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification import to.bitkit.repositories.PendingPaymentRepo @@ -172,6 +178,7 @@ import to.bitkit.services.MigrationService import to.bitkit.services.NodeServiceFgState import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SubscriptionRoute import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute @@ -184,6 +191,7 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger import to.bitkit.utils.NetworkValidationHelper +import to.bitkit.utils.ServiceError import to.bitkit.utils.jsonLogOf import to.bitkit.utils.timedsheets.TimedSheetManager import to.bitkit.utils.timedsheets.sheets.AppUpdateTimedSheet @@ -299,6 +307,11 @@ class AppViewModel @Inject constructor( val paymentRequestHistory = paykitPaymentRequestRepo.paymentRequestHistory val eligiblePaymentRequestTargets = paykitPaymentRequestRepo.eligibleTargets val isCreatingPaymentRequest = paykitPaymentRequestRepo.isCreatingRequest + private val _isAcceptingSubscription = MutableStateFlow(false) + val isAcceptingSubscription = _isAcceptingSubscription.asStateFlow() + private val _isRetryingInitialSubscriptionPayment = MutableStateFlow(false) + val isRetryingInitialSubscriptionPayment = _isRetryingInitialSubscriptionPayment.asStateFlow() + val subscriptions = paykitPaymentRequestRepo.subscriptions val pubkyContacts = pubkyRepo.contacts private var sheetTransitionJob: Job? = null private var paymentRequestSheetTransitionJob: Job? = null @@ -317,6 +330,10 @@ class AppViewModel @Inject constructor( private var activeContactPaymentContext: ContactPaymentContext? = null private val pendingContactPaymentContexts = mutableMapOf() private var requestedPaymentRequestId: PaykitPaymentRequestId? = null + private var requestedPaymentRequestIdentity: String? = null + private var requestedPaymentRequestTags: ImmutableList = persistentListOf() + private var uncertainOnchainPaymentRequestId: PaykitPaymentRequestId? = null + private val initialSubscriptionPaymentRequestIds = mutableSetOf() private var isPresentingPaymentRequest = false private var paymentRequestPresentationGeneration = 0L private var activePaymentRequestPresentationGeneration: Long? = null @@ -456,6 +473,7 @@ class AppViewModel @Inject constructor( observePaykitPaymentRequestConnectivity() observeInitialPaykitLinkBursts() observeIncomingPaykitPaymentRequests() + observePaykitOnchainPaymentResolution() observeSendEvents() viewModelScope.launch { checkCriticalAppUpdate() @@ -596,12 +614,11 @@ class AppViewModel @Inject constructor( if (!state.isPaykitEnabled || state.publicKey == null) { isPaymentRequestIdentityActivating = true lastPrivatePaykitContactKeys = emptySet() - invalidatePaymentRequestPresentation(dismissActiveRequest = paymentRequestIdentity != null) - clearPaymentRequestPresentationRetries() + resetPaykitPresentationState( + dismissActiveRequest = paymentRequestIdentity != null, + preserveRequestedPaymentRequest = paymentRequestIdentity == null, + ) paymentRequestIdentity = null - requestedPaymentRequestId = null - paymentRequestSheetTransitionJob?.cancel() - paymentRequestSheetTransitionJob = null try { paykitPaymentRequestRepo.clear() } finally { @@ -613,11 +630,11 @@ class AppViewModel @Inject constructor( val identityChanged = !PubkyPublicKeyFormat.matches(paymentRequestIdentity, state.publicKey) if (identityChanged) { - invalidatePaymentRequestPresentation(dismissActiveRequest = paymentRequestIdentity != null) - clearPaymentRequestPresentationRetries() - requestedPaymentRequestId = null - paymentRequestSheetTransitionJob?.cancel() - paymentRequestSheetTransitionJob = null + paykitPaymentProofRepo.clearOnchainPaymentResolution() + resetPaykitPresentationState( + dismissActiveRequest = paymentRequestIdentity != null, + preserveRequestedPaymentRequest = paymentRequestIdentity == null, + ) } isPaymentRequestIdentityActivating = true @@ -653,6 +670,7 @@ class AppViewModel @Inject constructor( isPaymentRequestIdentityActivating = false } } + presentNextIncomingPaykitPaymentRequest() } private suspend fun refreshPrivatePaykitEndpointsIfEnabled( @@ -689,6 +707,62 @@ class AppViewModel @Inject constructor( } } + private fun observePaykitOnchainPaymentResolution() { + viewModelScope.launch { + paykitPaymentProofRepo.onchainPaymentResolution + .filterNotNull() + .collect(::handlePaykitOnchainPaymentResolution) + } + } + + private fun handlePaykitOnchainPaymentResolution(resolution: PaykitOnchainPaymentProofResolution) { + if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, resolution.identity)) return + paykitPaymentProofRepo.consumeOnchainPaymentResolution(resolution) + val resolvesCurrentPayment = uncertainOnchainPaymentRequestId == resolution.requestId + if (!resolvesCurrentPayment) { + synchronizeResolvedPaykitOnchainPayment(resolution, updateSendDetails = false) + return + } + uncertainOnchainPaymentRequestId = null + if ( + _currentSheet.value !is Sheet.Send || + _sendUiState.value.incomingPaymentRequestId != resolution.requestId + ) { + synchronizeResolvedPaykitOnchainPayment(resolution, updateSendDetails = false) + return + } + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = resolution.transactionId, + sats = _sendUiState.value.amount.toLong(), + isLoadingDetails = true, + ) + ) + synchronizeResolvedPaykitOnchainPayment(resolution, updateSendDetails = true) + } + + private fun synchronizeResolvedPaykitOnchainPayment( + resolution: PaykitOnchainPaymentProofResolution, + updateSendDetails: Boolean, + ) { + viewModelScope.launch { + lightningRepo.sync() + activityRepo.syncActivities() + activityRepo.setContact( + contactPublicKey = resolution.requestId.counterparty, + forPaymentId = resolution.transactionId, + syncLdkPayments = false, + ).onFailure { + Logger.warn("Failed to associate a resolved Paykit payment with its contact", it, context = TAG) + } + if (updateSendDetails) { + _successSendUiState.update { it.copy(isLoadingDetails = false) } + } + } + } + private suspend fun refreshIncomingPaykitPaymentRequests(): Boolean { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return false paykitPaymentProofRepo.reconcile() @@ -721,6 +795,27 @@ class AppViewModel @Inject constructor( startInitialPaykitPaymentRequestPolling() } + fun synchronizeSubscriptionNotifications(enabled: Boolean) { + paykitPaymentRequestRepo.synchronizeSubscriptionNotifications(enabled) + } + + fun onPaykitSubscriptionNotificationTapped( + payerIdentity: String?, + requestId: PaykitPaymentRequestId? = null, + ) { + if (payerIdentity != null && requestId != null) { + val currentIdentity = pubkyRepo.publicKey.value + if (currentIdentity != null && !PubkyPublicKeyFormat.matches(currentIdentity, payerIdentity)) return + invalidatePaymentRequestPresentation() + requestedPaymentRequestId = requestId + requestedPaymentRequestIdentity = payerIdentity + requestedPaymentRequestTags = persistentListOf() + } + viewModelScope.launch { + refreshIncomingPaykitPaymentRequests() + } + } + fun stopPaykitPaymentRequestPolling() { paykitPaymentRequestPollingJob?.cancel() paykitPaymentRequestPollingJob = null @@ -765,13 +860,21 @@ class AppViewModel @Inject constructor( } fun onSheetVisible(sheet: Sheet?) { + if (sheet is Sheet.Subscription && sheet.route is SubscriptionRoute.Review) { + subscription(sheet.route.id)?.let { subscription -> + viewModelScope.launch { + paykitPaymentRequestRepo.markSubscriptionProposalPresented(subscription) + } + } + return + } if (sheet !is Sheet.Send || currentSheet.value !is Sheet.Send) return val request = activeIncomingPaymentRequest() ?: return viewModelScope.launch { if (currentSheet.value !is Sheet.Send || activeIncomingPaymentRequest()?.id != request.id) return@launch if (paykitPaymentRequestRepo.markPresented(request)) { paymentRequestPresentationGeneration++ - requestedPaymentRequestId = null + clearRequestedPaymentRequest() clearPaymentRequestPresentationRetry(request.id) } } @@ -779,6 +882,12 @@ class AppViewModel @Inject constructor( private suspend fun presentNextIncomingPaykitPaymentRequest() { if (isPresentingPaymentRequest || isPaymentRequestPresentationBlocked()) return + if (requestedPaymentRequestId == null) { + paykitPaymentRequestRepo.automaticSubscriptionProposals().firstOrNull()?.let { + showSheet(Sheet.Subscription(SubscriptionRoute.Review(it.id))) + return + } + } val requests = paymentRequestsForPresentation() ?: return val generation = paymentRequestPresentationGeneration isPresentingPaymentRequest = true @@ -807,19 +916,32 @@ class AppViewModel @Inject constructor( private fun paymentRequestsForPresentation(): List? { val requestedId = requestedPaymentRequestId - if (requestedId != null && paymentRequestPresentationRetryJobs[requestedId]?.isActive == true) return null - if (requestedId != null) { - val request = paykitPaymentRequestRepo.pendingRequest(requestedId) - if (request != null) return listOf(request) - invalidatePaymentRequestPresentation() - requestedPaymentRequestId = null - return null + return if (requestedId == null) { + paykitPaymentRequestRepo.automaticPendingRequests().filter { request -> + !paykitPaymentRequestRepo.isProcessing(request) && + paymentRequestPresentationRetryJobs[request.id]?.isActive != true + }.takeIf { it.isNotEmpty() } + } else { + when { + !requestedPaymentRequestTargetsCurrentIdentity() -> { + invalidatePaymentRequestPresentation() + clearRequestedPaymentRequest() + null + } + paymentRequestPresentationRetryJobs[requestedId]?.isActive == true -> null + else -> paykitPaymentRequestRepo.pendingRequest(requestedId)?.let(::listOf) ?: run { + invalidatePaymentRequestPresentation() + clearRequestedPaymentRequest() + null + } + } } + } - return paykitPaymentRequestRepo.automaticPendingRequests().filter { request -> - !paykitPaymentRequestRepo.isProcessing(request) && - paymentRequestPresentationRetryJobs[request.id]?.isActive != true - }.takeIf { it.isNotEmpty() } + private fun requestedPaymentRequestTargetsCurrentIdentity(): Boolean { + val requestedIdentity = requestedPaymentRequestIdentity ?: return true + val currentIdentity = pubkyRepo.publicKey.value ?: return false + return PubkyPublicKeyFormat.matches(currentIdentity, requestedIdentity) } private suspend fun presentIncomingPaymentRequestOrStop( @@ -833,7 +955,7 @@ class AppViewModel @Inject constructor( if (!paykitPaymentRequestRepo.isPending(request)) { if (requestedPaymentRequestId == request.id) { invalidatePaymentRequestPresentation() - requestedPaymentRequestId = null + clearRequestedPaymentRequest() } return false } @@ -847,6 +969,9 @@ class AppViewModel @Inject constructor( publicKey = request.counterparty, privatePaymentContext = result.privatePaymentContext, incomingPaymentRequest = request, + isInitialSubscriptionPayment = initialSubscriptionPaymentRequestIds.remove(request.id), + selectedTags = requestedPaymentRequestTags.takeIf { requestedPaymentRequestId == request.id } + ?: persistentListOf(), ) return true } @@ -866,7 +991,7 @@ class AppViewModel @Inject constructor( context = TAG, ) paymentRequestPresentationGeneration++ - requestedPaymentRequestId = null + clearRequestedPaymentRequest() showSheet(Sheet.PaymentRequests) viewModelScope.launch { paykitPaymentRequestRepo.markPresented(request) @@ -895,12 +1020,13 @@ class AppViewModel @Inject constructor( private fun retainPaymentRequestPresentationState(requests: List) { val requestIds = requests.mapTo(mutableSetOf()) { it.id } paymentRequestPresentationRetryAttempts.keys.retainAll(requestIds) + initialSubscriptionPaymentRequestIds.retainAll(requestIds) paymentRequestPresentationRetryJobs.keys.filter { it !in requestIds }.forEach { paymentRequestPresentationRetryJobs.remove(it)?.cancel() } if (requestedPaymentRequestId?.let { it !in requestIds } == true) { invalidatePaymentRequestPresentation() - requestedPaymentRequestId = null + clearRequestedPaymentRequest() } } @@ -915,6 +1041,23 @@ class AppViewModel @Inject constructor( paymentRequestPresentationRetryAttempts.clear() } + private fun resetPaykitPresentationState( + dismissActiveRequest: Boolean, + preserveRequestedPaymentRequest: Boolean, + ) { + invalidatePaymentRequestPresentation(dismissActiveRequest) + clearPaymentRequestPresentationRetries() + if (!preserveRequestedPaymentRequest) clearRequestedPaymentRequest() + paymentRequestSheetTransitionJob?.cancel() + paymentRequestSheetTransitionJob = null + } + + private fun clearRequestedPaymentRequest() { + requestedPaymentRequestId = null + requestedPaymentRequestIdentity = null + requestedPaymentRequestTags = persistentListOf() + } + private fun invalidatePaymentRequestPresentation(dismissActiveRequest: Boolean = false) { paymentRequestPresentationGeneration++ scheduledScan @@ -1306,7 +1449,8 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { (event.paymentHash ?: event.paymentId)?.let { paymentHash -> - viewModelScope.launch { paykitPaymentProofRepo.failLightningPayment(paymentHash) } + paykitPaymentProofRepo.failLightningPayment(paymentHash) + refreshIncomingPaykitPaymentRequests() } event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) @@ -1386,6 +1530,7 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { viewModelScope.launch { paykitPaymentProofRepo.completeLightningPayment(event.paymentHash, event.paymentPreimage) + refreshIncomingPaykitPaymentRequests() } event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) @@ -1549,6 +1694,8 @@ class AppViewModel @Inject constructor( } } SendEvent.SwipeToPay -> onSwipeToPay() + SendEvent.StartInitialSubscriptionPayment -> onStartInitialSubscriptionPayment() + SendEvent.CancelInitialSubscriptionPayment -> onCancelInitialSubscriptionPayment() is SendEvent.ConfirmAmountWarning -> onConfirmAmountWarning(it.warning) SendEvent.DismissAmountWarning -> onDismissAmountWarning() SendEvent.EstimateMaxRoutingFee -> viewModelScope.launch { @@ -1792,7 +1939,7 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean = false, contactPaymentContext: ContactPaymentContext? = null, preserveUntilComplete: Boolean = false, - ) { + ): Job? { if (!_isAuthenticated.value) { enqueueDeferredScan( source = source, @@ -1801,7 +1948,7 @@ class AppViewModel @Inject constructor( routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, ) - return + return null } val normalized = data.removeLightningSchemes() @@ -1813,12 +1960,12 @@ class AppViewModel @Inject constructor( (scheduled.contactPaymentContext == contactPaymentContext || contactPaymentContext == null) if (isSameActiveScan) { Logger.info("Skipping duplicate scan from '${source.label}': '$scanId'", context = TAG) - return + return null } if (scheduled?.job?.isActive == true && scheduled.mustComplete) { enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext) - return + return null } val previousJob = scheduled?.job @@ -1849,6 +1996,7 @@ class AppViewModel @Inject constructor( Logger.info("Cancelling prior scan for new '${source.label}': '$scanId'", context = TAG) it.cancel() } + return nextJob } private fun scanLogId(data: String): String { @@ -2176,13 +2324,21 @@ class AppViewModel @Inject constructor( publicKey: String, privatePaymentContext: PrivatePaykitPaymentContext? = null, incomingPaymentRequest: PaykitPaymentRequest? = null, - ) { + isInitialSubscriptionPayment: Boolean = false, + selectedTags: ImmutableList = persistentListOf(), + ): Job? { val context = ContactPaymentContext( publicKey = publicKey, privatePaymentContext = privatePaymentContext, incomingPaymentRequest = incomingPaymentRequest, + isInitialSubscriptionPayment = isInitialSubscriptionPayment, + selectedTags = selectedTags, + ) + return launchScan( + source = ScanSource.SCAN_RESULT, + data = paymentRequest, + contactPaymentContext = context, ) - onScanResult(paymentRequest, contactPaymentContext = context) } fun preserveContactPaymentContext(paymentHash: String) { @@ -2201,9 +2357,21 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean, ) = withContext(bgDispatcher) { val contactPaymentProfile = activeContactPaymentProfile() - val isPaymentRequest = activeIncomingPaymentRequest() != null + val incomingPaymentRequest = activeIncomingPaymentRequest() + val isPaymentRequest = incomingPaymentRequest != null // always reset state on new scan - resetSendState(contactPaymentProfile = contactPaymentProfile, isPaymentRequest = isPaymentRequest) + resetSendState( + contactPaymentProfile = contactPaymentProfile, + isPaymentRequest = isPaymentRequest, + isSubscriptionPayment = incomingPaymentRequest?.billingPeriod != null, + isInitialSubscriptionPayment = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.isInitialSubscriptionPayment == true + }, + incomingPaymentRequestId = incomingPaymentRequest?.id, + selectedTags = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.selectedTags ?: persistentListOf() + }, + ) resetQuickPay() val fromMainScanner = isMainScanner @@ -2866,6 +3034,18 @@ class AppViewModel @Inject constructor( } } + private fun onStartInitialSubscriptionPayment() { + if (!_sendUiState.value.initialSubscriptionPaymentAutoStartPending) return + _sendUiState.update { it.copy(initialSubscriptionPaymentAutoStartPending = false) } + onSwipeToPay() + } + + private fun onCancelInitialSubscriptionPayment() { + if (!_sendUiState.value.isInitialSubscriptionPayment) return + val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + handlePaymentPreparationFailure(PaykitPaymentRequestError.RequestUnavailable, contactPaymentContext) + } + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") private suspend fun handleSanityChecks(amountSats: ULong) { if (_sendUiState.value.showSanityWarningDialog != null) return @@ -2942,23 +3122,23 @@ class AppViewModel @Inject constructor( if (!validateIncomingPaymentRequest(contactPaymentContext)) return val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest - var preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).fold( + val preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).fold( onSuccess = { it }, onFailure = { - handlePaymentPreparationFailure(it) + handlePaymentPreparationFailure(it, contactPaymentContext) return }, ) consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { cancelPaymentProofPreparation(preparedPaymentProofRequest) - handlePaymentPreparationFailure(it) + handlePaymentPreparationFailure(it, contactPaymentContext) return } acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { cancelPaymentProofPreparation(preparedPaymentProofRequest) - handlePaymentPreparationFailure(it) + handlePaymentPreparationFailure(it, contactPaymentContext) return } @@ -2980,112 +3160,177 @@ class AppViewModel @Inject constructor( }.onFailure { cancelPaymentProofPreparation(preparedPaymentProofRequest) val message = getLnurlInvoiceFetchErrorMessage(it) - toast(Exception(message)) - hideSheet() + handlePaymentPreparationFailure(Exception(message), contactPaymentContext) return } } when (_sendUiState.value.payMethod) { - SendMethod.ONCHAIN -> { - val address = _sendUiState.value.address - val tags = _sendUiState.value.selectedTags - sendOnchain(address, amount, tags = tags) - .onSuccess { txId -> - preparedPaymentProofRequest = null - completeOnchainPaymentProof(incomingPaymentRequest, txId) - Logger.info("Onchain send result txid: $txId", context = TAG) - onSendSuccess( - NewTransactionSheetDetails( - type = NewTransactionSheetType.ONCHAIN, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = txId, - sats = amount.toLong(), - isLoadingDetails = true, - ) - ) - lightningRepo.sync() - activityRepo.syncActivities() - _successSendUiState.update { it.copy(isLoadingDetails = false) } - }.onFailure { e -> - cancelPaymentProofPreparation(preparedPaymentProofRequest) - Logger.error("Error sending onchain payment", e, context = TAG) - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__error_sending_title), - description = e.message ?: context.getString(R.string.common__error_body) - ) - hideSheet() - } - } + SendMethod.ONCHAIN -> proceedWithOnchainPayment( + incomingPaymentRequest, + preparedPaymentProofRequest, + contactPaymentContext, + amount, + ) - SendMethod.LIGHTNING -> { - val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) - val bolt11 = decodedInvoice.bolt11 + SendMethod.LIGHTNING -> proceedWithLightningPayment( + incomingPaymentRequest, + preparedPaymentProofRequest, + contactPaymentContext, + amount, + ) + } + } - val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount - val displayAmountSats = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount ?: 0uL + private suspend fun proceedWithOnchainPayment( + incomingPaymentRequest: PaykitPaymentRequest?, + preparedPaymentProofRequest: PaykitPaymentRequest?, + contactPaymentContext: ContactPaymentContext?, + amount: ULong, + ) { + val address = _sendUiState.value.address + val tags = _sendUiState.value.selectedTags + var proofRequest = preparedPaymentProofRequest + var onchainPaymentStarted = false + sendOnchain( + address = address, + amount = amount, + tags = tags, + beforeSendAttempt = { + if (incomingPaymentRequest != null) { + markOnchainPaymentStarted(incomingPaymentRequest, address).getOrThrow() + onchainPaymentStarted = true + } + }, + onBroadcast = { txId -> + proofRequest = null + completeOnchainPaymentProof(incomingPaymentRequest, txId) + }, + ).onSuccess { txId -> + Logger.info("Onchain send result txid: $txId", context = TAG) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txId, + sats = amount.toLong(), + isLoadingDetails = true, + ) + ) + lightningRepo.sync() + activityRepo.syncActivities() + _successSendUiState.update { it.copy(isLoadingDetails = false) } + }.onFailure { error -> + handleOnchainPaymentFailure( + error = error, + paymentStarted = onchainPaymentStarted, + incomingPaymentRequest = incomingPaymentRequest, + preparedPaymentProofRequest = proofRequest, + contactPaymentContext = contactPaymentContext, + ) + } + } - val tags = _sendUiState.value.selectedTags - var createdMetadataPaymentId: String? = null + private suspend fun handleOnchainPaymentFailure( + error: Throwable, + paymentStarted: Boolean, + incomingPaymentRequest: PaykitPaymentRequest?, + preparedPaymentProofRequest: PaykitPaymentRequest?, + contactPaymentContext: ContactPaymentContext?, + ) { + val amount = _sendUiState.value.amount + if (paymentStarted && !error.isDefiniteOnchainPreBroadcastFailure()) { + Logger.warn("On-chain payment outcome is uncertain after send started", error, context = TAG) + uncertainOnchainPaymentRequestId = incomingPaymentRequest?.id + paykitPaymentProofRepo.onchainPaymentResolution.value?.let(::handlePaykitOnchainPaymentResolution) + if (uncertainOnchainPaymentRequestId == null) return + setSendEffect( + SendEffect.NavigateToPending( + paymentHash = incomingPaymentRequest?.paymentRequestId.orEmpty(), + amount = amount.toLong(), + observeResolution = false, + ) + ) + return + } + if (paymentStarted) { + incomingPaymentRequest?.let { paykitPaymentProofRepo.failOnchainPayment(it) } + } + cancelPaymentProofPreparation(preparedPaymentProofRequest) + Logger.error("Error sending onchain payment", error, context = TAG) + if (contactPaymentContext?.isInitialSubscriptionPayment == true) { + setSendEffect(SendEffect.NavigateToError(error.toSendFailureDetails(context, _sendUiState.value.address))) + } else { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__error_sending_title), + description = error.message ?: context.getString(R.string.common__error_body), + ) + hideSheet() + } + } - // Extract payment hash from invoice for pre-activity metadata - val paymentHash = decodedInvoice.paymentHash.toHex() - associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { - cancelPaymentProofPreparation(preparedPaymentProofRequest) - handlePaymentPreparationFailure(it) - return - } + private suspend fun proceedWithLightningPayment( + incomingPaymentRequest: PaykitPaymentRequest?, + preparedPaymentProofRequest: PaykitPaymentRequest?, + contactPaymentContext: ContactPaymentContext?, + amount: ULong, + ) { + val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) + val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount + val displayAmountSats = decodedInvoice.amountSatoshis.takeIf { it > 0uL } ?: amount + var proofRequest = preparedPaymentProofRequest + var createdMetadataPaymentId: String? = null + val paymentHash = decodedInvoice.paymentHash.toHex() + associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { + cancelPaymentProofPreparation(proofRequest) + handlePaymentPreparationFailure(it, contactPaymentContext) + return + } - // Create pre-activity metadata before sending - if (tags.isNotEmpty()) { - preActivityMetadataRepo.savePreActivityMetadata( - id = paymentHash, - paymentHash = paymentHash, - address = _sendUiState.value.address, - isReceive = false, - tags = tags, - ).onSuccess { - createdMetadataPaymentId = paymentHash - } - } + val tags = _sendUiState.value.selectedTags + if (tags.isNotEmpty()) { + preActivityMetadataRepo.savePreActivityMetadata( + id = paymentHash, + paymentHash = paymentHash, + address = _sendUiState.value.address, + isReceive = false, + tags = tags, + ).onSuccess { + createdMetadataPaymentId = paymentHash + } + } - sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash -> - preparedPaymentProofRequest = null - Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) - onSendSuccess( - NewTransactionSheetDetails( - type = NewTransactionSheetType.LIGHTNING, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = actualPaymentHash, - sats = displayAmountSats.toLong(), // TODO Add fee when available - ), - ) - }.onFailure { - if (it is PaymentPendingException) { - preparedPaymentProofRequest = null - Logger.info("Lightning payment pending", context = TAG) - pendingPaymentRepo.track(it.paymentHash) - preserveContactPaymentContext(it.paymentHash) - setSendEffect(SendEffect.NavigateToPending(it.paymentHash, displayAmountSats.toLong())) - return@onFailure - } - paykitPaymentProofRepo.failLightningPayment(paymentHash) - cancelPaymentProofPreparation(preparedPaymentProofRequest) - // Delete pre-activity metadata on failure - if (createdMetadataPaymentId != null) { - preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) - } - Logger.error("Error sending lightning payment", it, context = TAG) - val failure = when (it) { - is LightningPaymentFailedError -> it.reason.toSendFailureDetails(context, it.paymentRequest) - else -> it.toSendFailureDetails(context, _sendUiState.value.currentLightningPaymentRequest()) - } - setSendEffect( - SendEffect.NavigateToError(failure) - ) - } + sendLightning(decodedInvoice.bolt11, paymentAmount).onSuccess { actualPaymentHash -> + proofRequest = null + Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = actualPaymentHash, + sats = displayAmountSats.toLong(), + ), + ) + }.onFailure { error -> + if (error is PaymentPendingException) { + proofRequest = null + Logger.info("Lightning payment pending", context = TAG) + pendingPaymentRepo.track(error.paymentHash) + preserveContactPaymentContext(error.paymentHash) + refreshIncomingPaykitPaymentRequests() + setSendEffect(SendEffect.NavigateToPending(error.paymentHash, displayAmountSats.toLong())) + return@onFailure } + paykitPaymentProofRepo.failLightningPayment(paymentHash) + cancelPaymentProofPreparation(proofRequest) + createdMetadataPaymentId?.let { preActivityMetadataRepo.deletePreActivityMetadata(it) } + Logger.error("Error sending lightning payment", error, context = TAG) + val failure = when (error) { + is LightningPaymentFailedError -> error.reason.toSendFailureDetails(context, error.paymentRequest) + else -> error.toSendFailureDetails(context, _sendUiState.value.currentLightningPaymentRequest()) + } + setSendEffect(SendEffect.NavigateToError(failure)) } } @@ -3112,9 +3357,16 @@ class AppViewModel @Inject constructor( txid = txId, paymentEndpointIdentifier = paymentProofPreparation().endpointIdentifier, ) + if (it.billingPeriod != null) refreshIncomingPaykitPaymentRequests() } } + private suspend fun markOnchainPaymentStarted( + request: PaykitPaymentRequest?, + address: String, + ): Result = request?.let { paykitPaymentProofRepo.markOnchainPaymentStarted(it, address) } + ?: Result.success(Unit) + private suspend fun cancelPaymentProofPreparation(request: PaykitPaymentRequest?) { request?.let { paykitPaymentProofRepo.cancelPreparation(it) } } @@ -3300,7 +3552,10 @@ class AppViewModel @Inject constructor( address: String, amount: ULong, tags: List = emptyList(), + beforeSendAttempt: suspend () -> Unit = {}, + onBroadcast: suspend (Txid) -> Unit = {}, ): Result { + var broadcastTxId: Txid? = null return lightningRepo.sendOnChain( address = address, sats = amount, @@ -3309,7 +3564,12 @@ class AppViewModel @Inject constructor( isMaxAmount = _sendUiState.value.payMethod == SendMethod.ONCHAIN && amount == walletRepo.balanceState.value.maxSendOnchainSats, tags = tags, - ) + beforeSendAttempt = beforeSendAttempt, + onBroadcast = { + broadcastTxId = it + onBroadcast(it) + }, + ).recoverCatching { broadcastTxId ?: throw it } } private suspend fun sendLightning( @@ -3491,6 +3751,10 @@ class AppViewModel @Inject constructor( suspend fun resetSendState( contactPaymentProfile: PubkyProfile? = null, isPaymentRequest: Boolean = false, + isSubscriptionPayment: Boolean = false, + isInitialSubscriptionPayment: Boolean = false, + incomingPaymentRequestId: PaykitPaymentRequestId? = null, + selectedTags: ImmutableList = persistentListOf(), ) { addressValidationJob?.cancel() val speed = settingsStore.data.first().defaultTransactionSpeed @@ -3506,6 +3770,11 @@ class AppViewModel @Inject constructor( feeRates = rates, contactPaymentProfile = contactPaymentProfile, isPaymentRequest = isPaymentRequest, + isSubscriptionPayment = isSubscriptionPayment, + isInitialSubscriptionPayment = isInitialSubscriptionPayment, + initialSubscriptionPaymentAutoStartPending = isInitialSubscriptionPayment, + incomingPaymentRequestId = incomingPaymentRequestId, + selectedTags = selectedTags, ) } } @@ -3641,6 +3910,17 @@ class AppViewModel @Inject constructor( } fun showSheet(sheetType: Sheet) { + val replacesInitialSubscriptionInPlace = _currentSheet.value is Sheet.Subscription && + sheetType is Sheet.Send && + _sendUiState.value.isInitialSubscriptionPayment + if (replacesInitialSubscriptionInPlace) { + sheetTransitionJob?.cancel() + sheetTransitionJob = null + receiveSheetContext = null + _currentSheet.update { sheetType } + return + } + val previousJob = sheetTransitionJob val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { receiveSheetContext = null @@ -3917,7 +4197,106 @@ class AppViewModel @Inject constructor( showSheet(Sheet.PaymentRequests) } + fun subscription(id: PaykitSubscriptionId): PaykitSubscription? = + paykitPaymentRequestRepo.subscriptions.value.firstOrNull { it.id == id } + + fun subscriptionAcceptedAt(id: PaykitSubscriptionId) = + subscription(id)?.let(paykitPaymentRequestRepo::acceptedAt) + + suspend fun acceptSubscriptionAndStartPayment( + displayedSubscription: PaykitSubscription, + ): Result { + if (!_isAcceptingSubscription.compareAndSet(false, true)) { + return Result.failure(PaykitPaymentRequestError.OperationInProgress).onFailure(::toast) + } + + return try { + runSuspendCatching { + val subscription = subscription(displayedSubscription.id) + ?.takeIf { it == displayedSubscription } + ?: throw PaykitPaymentRequestError.RequestUnavailable + val acceptedDueRequest = paykitPaymentRequestRepo.accept(subscription).getOrThrow() + if (acceptedDueRequest == null) { + val accepted = subscription(displayedSubscription.id) + if (accepted?.lifecycleState != PaymentRequestLifecycleState.ACTIVE_RECURRING) { + throw PaykitPaymentRequestError.RequestUnavailable + } + return@runSuspendCatching false + } + + val resolution = privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(acceptedDueRequest) + .getOrElse { error -> + showInitialSubscriptionPaymentFailure(acceptedDueRequest, error) + return@runSuspendCatching true + } + if (resolution !is PublicPaykitPaymentResult.Opened) { + showInitialSubscriptionPaymentFailure( + acceptedDueRequest, + PaykitPaymentRequestError.RequestUnavailable, + ) + return@runSuspendCatching true + } + val scanJob = openContactPayment( + paymentRequest = resolution.paymentRequest, + publicKey = acceptedDueRequest.counterparty, + privatePaymentContext = resolution.privatePaymentContext, + incomingPaymentRequest = acceptedDueRequest, + isInitialSubscriptionPayment = true, + ) + scanJob?.join() + if (_currentSheet.value !is Sheet.Send) { + paykitPaymentRequestRepo.markPresented(acceptedDueRequest) + val error = PaykitPaymentRequestError.RequestUnavailable + val failure = error.toSendFailureDetails( + context, + _sendUiState.value.currentLightningPaymentRequest() + ) + showSheet(Sheet.Send(SendRoute.errorFromFailure(failure))) + } + true + }.onFailure(::toast) + } finally { + _isAcceptingSubscription.update { false } + } + } + + private suspend fun showInitialSubscriptionPaymentFailure( + request: PaykitPaymentRequest, + error: Throwable, + ) { + val paymentContext = ContactPaymentContext( + publicKey = request.counterparty, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + ) + setActiveContactPaymentContext(paymentContext) + resetSendState( + contactPaymentProfile = activeContactPaymentProfile(), + isPaymentRequest = true, + isSubscriptionPayment = true, + isInitialSubscriptionPayment = true, + incomingPaymentRequestId = request.id, + ) + paykitPaymentRequestRepo.markPresented(request) + val failure = error.toSendFailureDetails(context, paymentRequest = null) + if (_currentSheet.value is Sheet.Send) { + setSendEffect(SendEffect.NavigateToError(failure)) + } else { + showSheet(Sheet.Send(SendRoute.errorFromFailure(failure))) + } + } + + suspend fun cancelSubscription(id: PaykitSubscriptionId): Result { + val subscription = subscription(id) ?: return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + return paykitPaymentRequestRepo.cancel(subscription) + .onFailure(::toast) + } + fun openIncomingPaymentRequest(id: PaykitPaymentRequestId) { + openIncomingPaymentRequestWithTags(id, emptyList()) + } + + fun openIncomingPaymentRequestWithTags(id: PaykitPaymentRequestId, tags: List) { val request = paykitPaymentRequestRepo.pendingRequest(id) ?: return if (paykitPaymentRequestRepo.isProcessing(request) || requestedPaymentRequestId != null) { toast(PaykitPaymentRequestError.OperationInProgress) @@ -3925,8 +4304,13 @@ class AppViewModel @Inject constructor( } invalidatePaymentRequestPresentation() requestedPaymentRequestId = id + requestedPaymentRequestTags = tags.filter(String::isNotBlank).distinct().toImmutableList() - if (_currentSheet.value is Sheet.PaymentRequests) { + if ( + _currentSheet.value is Sheet.PaymentRequests || + _currentSheet.value is Sheet.Subscription || + _currentSheet.value is Sheet.Send + ) { hideSheet(shouldFlushDeferredScan = false) paymentRequestSheetTransitionJob?.cancel() val job = viewModelScope.launch { @@ -3940,11 +4324,62 @@ class AppViewModel @Inject constructor( } } - suspend fun rejectIncomingPaymentRequest(request: PaykitPaymentRequest): Result { + fun retryIncomingPaymentRequest(id: PaykitPaymentRequestId) { + if (_sendUiState.value.isInitialSubscriptionPayment && _currentSheet.value is Sheet.Send) { + retryInitialSubscriptionPaymentInCurrentSheet(id, _sendUiState.value.selectedTags) + return + } + clearActiveContactPaymentContext() + viewModelScope.launch { + refreshIncomingPaykitPaymentRequests() + openIncomingPaymentRequestWithTags(id, _sendUiState.value.selectedTags) + } + } + + private fun retryInitialSubscriptionPaymentInCurrentSheet( + id: PaykitPaymentRequestId, + tags: ImmutableList, + ) { + if (!_isRetryingInitialSubscriptionPayment.compareAndSet(false, true)) { + toast(PaykitPaymentRequestError.OperationInProgress) + return + } + clearActiveContactPaymentContext() + viewModelScope.launch { + try { + refreshIncomingPaykitPaymentRequests() + val request = paykitPaymentRequestRepo.pendingRequest(id) ?: run { + toast(PaykitPaymentRequestError.RequestUnavailable) + return@launch + } + val resolution = privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request).getOrElse { + showInitialSubscriptionPaymentFailure(request, it) + return@launch + } + if (resolution !is PublicPaykitPaymentResult.Opened) { + showInitialSubscriptionPaymentFailure(request, PaykitPaymentRequestError.RequestUnavailable) + return@launch + } + val scanJob = openContactPayment( + paymentRequest = resolution.paymentRequest, + publicKey = request.counterparty, + privatePaymentContext = resolution.privatePaymentContext, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + selectedTags = tags, + ) + scanJob?.join() + } finally { + _isRetryingInitialSubscriptionPayment.update { false } + } + } + } + + suspend fun dismissIncomingPaymentRequest(request: PaykitPaymentRequest): Result { if (requestedPaymentRequestId == request.id) { return Result.failure(PaykitPaymentRequestError.OperationInProgress).onFailure(::toast) } - return paykitPaymentRequestRepo.reject(request).onFailure(::toast) + return paykitPaymentRequestRepo.dismiss(request).onFailure(::toast) } private suspend fun createPaymentRequest( @@ -3985,9 +4420,17 @@ class AppViewModel @Inject constructor( } } - private fun handlePaymentPreparationFailure(error: Throwable) { - toast(error) - hideSheet() + private fun handlePaymentPreparationFailure(error: Throwable, contactPaymentContext: ContactPaymentContext?) { + if (contactPaymentContext?.isInitialSubscriptionPayment == true) { + setSendEffect( + SendEffect.NavigateToError( + error.toSendFailureDetails(context, _sendUiState.value.currentLightningPaymentRequest()) + ) + ) + } else { + toast(error) + hideSheet() + } } fun handleDeeplinkIntent(intent: Intent) { @@ -4301,6 +4744,10 @@ data class SendUiState( val lastLightningFee: Long = 0L, val contactPaymentProfile: PubkyProfile? = null, val isPaymentRequest: Boolean = false, + val isSubscriptionPayment: Boolean = false, + val isInitialSubscriptionPayment: Boolean = false, + val initialSubscriptionPaymentAutoStartPending: Boolean = false, + val incomingPaymentRequestId: PaykitPaymentRequestId? = null, ) enum class SanityWarning(@StringRes val message: Int, val testTag: String) { @@ -4322,6 +4769,8 @@ data class ContactPaymentContext( val publicKey: String, val privatePaymentContext: PrivatePaykitPaymentContext? = null, val incomingPaymentRequest: PaykitPaymentRequest? = null, + val isInitialSubscriptionPayment: Boolean = false, + val selectedTags: ImmutableList = persistentListOf(), ) private data class PaymentProofPreparation( @@ -4352,7 +4801,11 @@ sealed class SendEffect { data object NavigateToComingSoon : SendEffect() data object PaymentSuccess : SendEffect() data class NavigateToError(val failure: SendFailureDetails) : SendEffect() - data class NavigateToPending(val paymentHash: String, val amount: Long) : SendEffect() + data class NavigateToPending( + val paymentHash: String, + val amount: Long, + val observeResolution: Boolean = true, + ) : SendEffect() } sealed class MainScreenEffect { @@ -4383,6 +4836,8 @@ sealed interface SendEvent { data class CommentChange(val value: String) : SendEvent data object SwipeToPay : SendEvent + data object StartInitialSubscriptionPayment : SendEvent + data object CancelInitialSubscriptionPayment : SendEvent data object SpeedAndFee : SendEvent data object PaymentMethodSwitch : SendEvent data class ConfirmAmountWarning(val warning: SanityWarning) : SendEvent @@ -4400,6 +4855,23 @@ private class LightningPaymentFailedError( val paymentRequest: String?, ) : AppError(reason?.name) +private fun Throwable.isDefiniteOnchainPreBroadcastFailure(): Boolean = + generateSequence(this as Throwable?) { it.cause } + .any { + it is ServiceError.NodeNotSetup || + it is ServiceError.NodeNotStarted || + it is NodeException.NotRunning || + it is NodeException.OnchainTxCreationFailed || + it is NodeException.OnchainTxSigningFailed || + it is NodeException.InvalidAddress || + it is NodeException.InvalidAmount || + it is NodeException.InvalidNetwork || + it is NodeException.InvalidFeeRate || + it is NodeException.InsufficientFunds || + it is NodeException.CoinSelectionFailed || + it is NodeException.NoSpendableOutputs + } + sealed interface LnurlParams { data class LnurlPay(val data: LnurlPayData) : LnurlParams data class LnurlWithdraw(val data: LnurlWithdrawData) : LnurlParams diff --git a/app/src/main/res/drawable-nodpi/subscription_clock.png b/app/src/main/res/drawable-nodpi/subscription_clock.png new file mode 100644 index 0000000000..2633f499bc Binary files /dev/null and b/app/src/main/res/drawable-nodpi/subscription_clock.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43537a468b..8defb150f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1014,6 +1014,57 @@ Set up your public profile and links, so your Bitkit contacts can reach you or pay you anytime, anywhere. Profile Create Profile + Active + Cancel + Cancel Subscription + Daily + Daily Subscription + Subscription Details + Due This Month + At the moment, you don’t have any active subscriptions from any providers. + Subscriptions]]> + Every %1$d days + Every %1$d months + Every %1$d weeks + Every %1$d years + Expired + Expires + Expires %1$s + First Payment Failed + You’re subscribed, but your first payment wasn’t sent. + Frequency + More Info + Monthly + Monthly Subscription + Ongoing + Overview + Open Bitkit to review a subscription payment. + Subscription Payment Due + Payments + Per Day + Per Month + Per Week + Per Year + Proposals + Renews + Renews %1$s + Review & Subscribe + Retry Payment + Status + Subscribed + Subscription + Swipe To Cancel + Swipe To Subscribe + Swipe To Subscribe & Pay + Subscriptions + This subscription is no longer available. + This subscription uses a payment frequency that Bitkit does not support yet. + Unsupported Frequency + This subscription uses payment details that Bitkit does not support yet. + Weekly + Weekly Subscription + Yearly + Yearly Subscription %1$s sats Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). LOOKING FOR FUNDS... @@ -1125,7 +1176,6 @@ Incoming Transfer: Activity Contacts - Requests Profile Settings Shop @@ -1171,9 +1221,10 @@ Payment Request Amount Choose Recipient + Contact %1$s - %2$s Dismiss - Edit expiration + Date Enter pubky Expires in 1 day @@ -1184,10 +1235,14 @@ Note What is this payment for? Pay + Request Or Pay + Pay %1$s or request a payment. + Or Pay ₿]]> Paste Your payment request is queued and will send automatically RECIPIENT Request Payment + Request Send Payment Request Send Request You have sent a payment request @@ -1202,6 +1257,7 @@ Rejected Unavailable %1$s at %2$s + Time Waiting for payment Waiting for %1$s to pay Waiting for updated private payment details. Bitkit will retry automatically. @@ -1221,7 +1277,6 @@ This Week This Year Today - Yesterday Bitkit tried several Lightning routes, but the payment could not be completed. Bitkit couldn\'t find a Lightning route for this payment. Payment timed out. Please try again. diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index bace953693..2a51e13f14 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import com.synonym.paykit.BillingPeriod import com.synonym.paykit.IdentityStatus import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference @@ -12,14 +13,12 @@ import com.synonym.paykit.PrivateJsonObject import kotlinx.coroutines.test.StandardTestDispatcher import org.junit.Before import org.junit.Test -import org.lightningdevkit.ldknode.PaymentDetails -import org.lightningdevkit.ldknode.PaymentDirection -import org.lightningdevkit.ldknode.PaymentKind -import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -31,6 +30,7 @@ import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Instant class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { companion object { @@ -38,11 +38,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private const val COUNTERPARTY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val PAYMENT_REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000" private const val PAYMENT_HASH = "66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925" + private const val ONCHAIN_ADDRESS = "bcrt1qpaymentproof" private val PREIMAGE = "00".repeat(32) } private val paykitSdkService = mock() private val lightningRepo = mock() + private val onchainPaymentLookup = mock() private val store = mock() private var storedProofs = emptyList() private var shouldFailNextLoad = false @@ -55,6 +57,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailNextSave = false whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(onchainPaymentLookup.existingTransactionIds(any(), any())).thenReturn(emptySet()) whenever(store.load()).thenAnswer { if (shouldFailNextLoad) { shouldFailNextLoad = false @@ -65,7 +68,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(store.save(any())).doSuspendableAnswer { if (shouldFailNextSave) { shouldFailNextSave = false - error("temporary save failure") + error("transient save failure") } storedProofs = it.getArgument(0) } @@ -76,7 +79,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord() val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) .thenThrow(IllegalStateException("temporary failure")) .thenReturn(record) val firstRepo = paymentProofRepo() @@ -97,6 +100,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestId = any(), paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), + billingPeriod = isNull(), ) assertEquals(MethodId.Bolt11.rawValue, endpointCaptor.lastValue) assertEquals( @@ -107,46 +111,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService).processPendingPrivateMessages() } - @Test - fun `associated lightning proof completes after repository restart`() = test { - val record = paymentRequestRecord() - val request = paymentRequest(MethodId.Bolt11.rawValue) - val paymentKind = mock { - on { preimage } doReturn PREIMAGE - } - val payment = mock { - on { id } doReturn PAYMENT_HASH - on { kind } doReturn paymentKind - on { direction } doReturn PaymentDirection.OUTBOUND - on { status } doReturn PaymentStatus.SUCCEEDED - } - whenever(lightningRepo.getPayments()).thenReturn(Result.success(listOf(payment))) - whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) - val firstRepo = paymentProofRepo() - - firstRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - firstRepo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() - assertNull(storedProofs.single().proofData) - - paymentProofRepo().reconcile() - - verify(lightningRepo).getPayments() - val proofCaptor = argumentCaptor() - verify(paykitSdkService).submitPaymentProof( - counterparty = any(), - counterpartyReceiverPath = any(), - paymentRequestId = any(), - paymentEndpointIdentifier = any(), - proofJson = proofCaptor.capture(), - ) - assertEquals( - """{"data":"$PREIMAGE","type":"${PaykitPaymentProofKind.Lightning.type}"}""", - proofCaptor.firstValue, - ) - assertTrue(storedProofs.isEmpty()) - } - @Test fun `mismatched lightning preimage is not submitted`() = test { val request = paymentRequest(MethodId.Bolt11.rawValue) @@ -157,7 +121,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, "01".repeat(32)) assertNull(storedProofs.single().proofData) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) } @Test @@ -180,7 +144,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) } @Test @@ -193,7 +157,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.failLightningPayment(PAYMENT_HASH) assertTrue(storedProofs.isEmpty()) - verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) } @Test @@ -202,10 +166,12 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + assertTrue(storedProofs.single().paymentStarted) repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) val endpointCaptor = argumentCaptor() @@ -216,6 +182,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { any(), endpointCaptor.capture(), proofCaptor.capture(), + isNull(), ) assertEquals(MethodId.P2wpkh.rawValue, endpointCaptor.firstValue) assertEquals( @@ -226,22 +193,102 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `lightning retry preserves earlier payment correlation`() = test { + fun `started onchain payment survives preparation cancellation`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.cancelPreparation(request) + + assertTrue(storedProofs.single().paymentStarted) + } + + @Test + fun `definite onchain failure clears started proof`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.failOnchainPayment(request) + + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `recurring proof includes the exact billing period`() = test { + val period = PaykitBillingPeriod( + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + endsAt = Instant.parse("2027-02-01T08:00:00Z"), + ) + val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) val record = paymentRequestRecord() - val request = paymentRequest(MethodId.Bolt11.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, "aa".repeat(32)).getOrThrow() + repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + + val periodCaptor = argumentCaptor() + verify(paykitSdkService).submitPaymentProof( + counterparty = any(), + counterpartyReceiverPath = any(), + paymentRequestId = any(), + paymentEndpointIdentifier = any(), + proofJson = any(), + billingPeriod = periodCaptor.capture(), + ) + assertEquals(period, periodCaptor.firstValue) + } + @Test + fun `proof from earlier billing period does not suppress recurring payment`() = test { + val currentPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2027-02-01T08:00:00Z"), + endsAt = Instant.parse("2027-03-01T08:00:00Z"), + ) + val existingProofJson = mock { + on { exportText() } doReturn """{"type":"${PaykitPaymentProofKind.Lightning.type}","data":"$PREIMAGE"}""" + } + val existingProof = mock { + on { billingPeriod } doReturn BillingPeriod( + startsAt = "2027-01-01T08:00:00.000Z", + endsAt = "2027-02-01T08:00:00.000Z", + ) + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + on { proof } doReturn existingProofJson + } + val record = paymentRequestRecord(listOf(existingProof)) + val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = currentPeriod) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), any())).thenReturn(record) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any()) - assertTrue(storedProofs.isEmpty()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `lightning retry is rejected while earlier payment is unresolved`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + val retry = repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + + assertTrue(retry.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) + assertEquals(PAYMENT_HASH, storedProofs.single().paymentIdentifier) + + repo.failLightningPayment(PAYMENT_HASH) + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + assertEquals(1, storedProofs.size) } @Test @@ -265,17 +312,37 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(MethodId.P2wpkh.rawValue) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextSave = true repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) - verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertTrue(storedProofs.isEmpty()) } + @Test + fun `completed onchain proof remains durable when persistence and submission initially fail`() = test { + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val record = paymentRequestRecord() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenThrow(IllegalStateException("transient submission failure")) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + shouldFailNextSave = true + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + + assertEquals(txid, storedProofs.single().proofData) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + } + @Test fun `onchain proof submits when prepared proof cannot be loaded`() = test { val txid = "ab".repeat(32) @@ -283,10 +350,11 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val request = paymentRequest(endpoint) val record = paymentRequestRecord() whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextLoad = true repo.completeOnchainPayment(request, txid, endpoint) @@ -298,6 +366,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentRequestId = any(), paymentEndpointIdentifier = endpointCaptor.capture(), proofJson = proofCaptor.capture(), + billingPeriod = isNull(), ) assertEquals(endpoint, endpointCaptor.firstValue) assertEquals( @@ -307,16 +376,127 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `uncertain onchain payment is reconciled from its private destination`() = test { + val txid = "ab".repeat(32) + val record = paymentRequestRecord() + val request = paymentRequest(MethodId.P2wpkh.rawValue) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) + whenever(onchainPaymentLookup.transactionId(ONCHAIN_ADDRESS, request.amountSats, emptySet())).thenReturn(txid) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.reconcile() + + assertTrue(storedProofs.isEmpty()) + val proofCaptor = argumentCaptor() + verify(paykitSdkService).submitPaymentProof( + counterparty = eq(request.counterparty), + counterpartyReceiverPath = eq(request.counterpartyReceiverPath), + paymentRequestId = eq(request.paymentRequestId), + paymentEndpointIdentifier = eq(MethodId.P2wpkh.rawValue), + proofJson = proofCaptor.capture(), + billingPeriod = isNull(), + ) + assertTrue(proofCaptor.firstValue.contains(txid)) + } + + @Test + fun `uncertain onchain payment ignores transaction from before attempt`() = test { + val oldTransactionId = "ab".repeat(32) + val record = paymentRequestRecord() + val request = paymentRequest(MethodId.P2wpkh.rawValue) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(onchainPaymentLookup.existingTransactionIds(any(), any())).thenReturn(setOf(oldTransactionId)) + whenever( + onchainPaymentLookup.transactionId( + ONCHAIN_ADDRESS, + request.amountSats, + setOf(oldTransactionId), + ) + ).thenReturn(null) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + repo.reconcile() + + assertEquals(setOf(oldTransactionId), storedProofs.single().onchainMatchingTransactionIdsBeforeAttempt) + assertNull(storedProofs.single().proofData) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `cancel preparation does not remove another identity proof`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + val otherIdentityProof = PendingPaykitPaymentProof( + identity = "pubky${"a".repeat(52)}", + requestId = request.id, + paymentEndpointIdentifier = MethodId.Bolt11.rawValue, + kind = PaykitPaymentProofKind.Lightning, + ) + storedProofs = listOf(otherIdentityProof) + val repo = paymentProofRepo() + + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.cancelPreparation(request) + + assertEquals(listOf(otherIdentityProof), storedProofs) + } + + @Test + fun `subscription cancellation discards only unstarted preparation`() = test { + val period = PaykitBillingPeriod( + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + endsAt = Instant.parse("2027-02-01T08:00:00Z"), + ) + val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + + val protectedRequestIds = repo.protectedRequestIdsForSubscriptionCancellation( + LOCAL_IDENTITY, + PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.WALLET), + ).getOrThrow() + + assertTrue(protectedRequestIds.isEmpty()) + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `subscription cancellation preserves a started payment`() = test { + val period = PaykitBillingPeriod( + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + endsAt = Instant.parse("2027-02-01T08:00:00Z"), + ) + val request = paymentRequest(MethodId.Bolt11.rawValue, billingPeriod = period) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH).getOrThrow() + + val protectedRequestIds = repo.protectedRequestIdsForSubscriptionCancellation( + LOCAL_IDENTITY, + PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.WALLET), + ).getOrThrow() + + assertEquals(setOf(request.id), protectedRequestIds) + assertEquals(listOf(request.id), storedProofs.map { it.requestId }) + } + private fun paymentProofRepo() = PaykitPaymentProofRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, lightningRepo = lightningRepo, + onchainPaymentLookup = onchainPaymentLookup, store = store, ) private fun paymentRequest( endpoint: String, paymentRequestId: String = PAYMENT_REQUEST_ID, + billingPeriod: PaykitBillingPeriod? = null, ) = PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = COUNTERPARTY, @@ -325,6 +505,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { amountSats = 1_000uL, expiresAt = null, acceptedPaymentEndpointIdentifiers = listOf(endpoint), + billingPeriod = billingPeriod, ) private fun paymentRequestRecord(paymentProofs: List = emptyList()) = PaymentRequestRecord( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt new file mode 100644 index 0000000000..eb805e1407 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -0,0 +1,521 @@ +@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.LinkedPeerRecord +import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaymentProofRecord +import com.synonym.paykit.PaymentReference +import com.synonym.paykit.PaymentRequestAmount +import com.synonym.paykit.PaymentRequestLifecycleState +import com.synonym.paykit.PaymentRequestLocalRole +import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestRecurrence +import com.synonym.paykit.PaymentRequestTerms +import com.synonym.paykit.PrivateJsonObject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.services.PaykitReceiverPaths +import to.bitkit.services.PaykitSdkService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispatcher()) { + private companion object { + const val PAYMENT_REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000" + const val COUNTERPARTY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + const val LOCAL_IDENTITY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val START_TIME = Instant.parse("2027-01-15T08:00:00Z") + val PAYMENT_REFERENCE = mock { + on { exportText() } doReturn "invoice-123" + } + val METADATA = mock { + on { exportText() } doReturn """{"order":"123"}""" + } + } + + private val paykitSdkService = mock() + private val settingsStore = mock() + private val presentationStore = mock() + private val paymentProofStore = mock() + private val paymentProofRepo = mock() + private val notificationScheduler = mock() + private var schedulerOriginMillis = 0L + private val clock = object : Clock { + override fun now(): Instant = START_TIME.plus( + (testDispatcher.scheduler.currentTime - schedulerOriginMillis).milliseconds, + ) + } + private lateinit var sut: PaykitPaymentRequestRepo + + @Before + fun setUp() = test { + schedulerOriginMillis = testDispatcher.scheduler.currentTime + whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) + whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) + whenever(presentationStore.load(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever( + presentationStore.loadSubscriptionState(any()) + ).thenReturn(PaykitSubscriptionPresentationState()) + whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptyMap()) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(any(), any())) + .thenReturn(Result.success(emptySet())) + sut = PaykitPaymentRequestRepo( + testDispatcher, + paykitSdkService, + settingsStore, + presentationStore, + paymentProofStore, + paymentProofRepo, + notificationScheduler, + clock, + ) + sut.activate(LOCAL_IDENTITY) + } + + @After + fun tearDown() = test { + sut.clear() + } + + @Test + fun `refresh maps active subscription and exposes current unpaid period`() = test { + val metadataText = """ + {"note":"Mobile plan","subscription":{"version":1,"description":"10 GB every month","benefits":["Roaming"]}} + """.trimIndent() + val metadata = mock { + on { exportText() } doReturn metadataText + } + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord( + id = "recurring", + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + metadata = metadata, + ), + ), + ) + + sut.refresh(emptyList()).getOrThrow() + + val subscription = sut.subscriptions.value.single() + assertEquals("Mobile plan", subscription.note) + assertEquals("10 GB every month", subscription.metadata.description) + assertEquals(listOf("Roaming"), subscription.metadata.benefits) + val request = sut.pendingRequests.value.single() + assertEquals("recurring", request.paymentRequestId) + assertFalse(request.requiresAcceptance) + assertEquals(Instant.parse("2027-01-01T08:00:00Z"), request.billingPeriod?.startsAt) + assertEquals(Instant.parse("2027-02-01T08:00:00Z"), request.billingPeriod?.endsAt) + } + + @Test + fun `accepting subscription returns current period and preserves payment targets`() = test { + val proposal = paymentRequestRecord() + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposal), listOf(active)) + whenever( + paykitSdkService.acceptPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ) + ).thenReturn(active) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) + .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + sut.refresh(listOf(COUNTERPARTY)).getOrThrow() + + val subscription = sut.subscriptions.value.single() + val dueRequest = sut.accept(subscription).getOrThrow() + + assertEquals(Instant.parse("2027-01-01T08:00:00Z"), dueRequest?.billingPeriod?.startsAt) + assertEquals(listOf(COUNTERPARTY), sut.eligibleTargets.value.map { it.publicKey }) + verifyBlocking(presentationStore) { + saveSubscriptionState( + eq(LOCAL_IDENTITY), + argThat { subscription.id in acceptedAt }, + ) + } + } + + @Test + fun `accepting subscription rejects terms changed after review`() = test { + val reviewedRecord = paymentRequestRecord() + val changedRecord = paymentRequestRecord(amount = "0.002") + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(reviewedRecord), listOf(changedRecord)) + sut.refresh(emptyList()).getOrThrow() + val reviewedSubscription = sut.subscriptions.value.single() + sut.refresh(emptyList()).getOrThrow() + + val result = sut.accept(reviewedSubscription) + + assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.RequestUnavailable) + verifyBlocking(paykitSdkService, never()) { acceptPaymentRequest(any(), any(), any()) } + } + + @Test + fun `accepted subscription stays successful when its immediate refresh fails`() = test { + val proposal = paymentRequestRecord() + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + whenever(paykitSdkService.paymentRequests()) + .thenReturn(listOf(proposal)) + .thenThrow(IllegalStateException("refresh failed")) + whenever( + paykitSdkService.acceptPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ) + ).thenReturn(active) + sut.refresh(emptyList()).getOrThrow() + + val dueRequest = sut.accept(sut.subscriptions.value.single()).getOrThrow() + + assertEquals(PaymentRequestLifecycleState.ACTIVE_RECURRING, sut.subscriptions.value.single().lifecycleState) + assertEquals(Instant.parse("2027-01-01T08:00:00Z"), dueRequest?.billingPeriod?.startsAt) + assertEquals(listOf(dueRequest), sut.pendingRequests.value) + } + + @Test + fun `dismissed subscription period stays out of queue after refresh`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), + ) + sut.refresh(emptyList()).getOrThrow() + val request = sut.pendingRequests.value.single() + + assertTrue(sut.dismissSubscriptionPayment(request)) + assertTrue(sut.pendingRequests.value.isEmpty()) + + sut.refresh(emptyList()).getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + verifyBlocking(presentationStore) { + saveSubscriptionState(eq(LOCAL_IDENTITY), argThat { dismissedPaymentIds == setOf(request.id) }) + } + } + + @Test + fun `completed subscription payment awaiting proof submission is not offered again`() = test { + val requestId = PaykitPaymentRequestId( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + billingPeriodStartsAt = "2027-01-01T08:00:00Z", + ) + whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) + .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), + ) + + sut.refresh(emptyList()).getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(requestId, sut.paymentRequestHistory.value.single().id) + assertEquals( + PaymentRequestLifecycleState.PROOF_SUBMITTED, + sut.paymentRequestHistory.value.single().lifecycleState, + ) + assertEquals(PaykitPaymentProofKind.Onchain, sut.paymentRequestHistory.value.single().paymentProofKind) + } + + @Test + fun `completed subscription payment retains its SDK payment rail`() = test { + val proof = mock { + on { billingPeriod } doReturn BillingPeriod( + startsAt = "2027-01-01T08:00:00Z", + endsAt = "2027-02-01T08:00:00Z", + ) + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord( + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paymentProofs = listOf(proof), + ), + ), + ) + + sut.refresh(emptyList()).getOrThrow() + + val request = sut.paymentRequestHistory.value.single() + assertEquals(PaymentRequestLifecycleState.PROOF_SUBMITTED, request.lifecycleState) + assertEquals(PaykitPaymentProofKind.Lightning, request.paymentProofKind) + } + + @Test + fun `in flight subscription payment is neither offered nor marked paid`() = test { + val requestId = PaykitPaymentRequestId( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + billingPeriodStartsAt = "2027-01-01T08:00:00Z", + ) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(setOf(requestId)) + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING)), + ) + + sut.refresh(emptyList()).getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) + } + + @Test + fun `subscription cannot be canceled after payment has started`() = test { + val requestId = PaykitPaymentRequestId( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + billingPeriodStartsAt = "2027-01-01T08:00:00Z", + ) + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(eq(LOCAL_IDENTITY), any())) + .thenReturn(Result.success(setOf(requestId))) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active)) + sut.refresh(emptyList()).getOrThrow() + + val result = sut.cancel(sut.subscriptions.value.single()) + + assertTrue(result.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) + assertEquals(1, sut.subscriptions.value.size) + verifyBlocking(paykitSdkService, never()) { cancelPaymentRequest(any(), any(), any(), anyOrNull()) } + } + + @Test + fun `subscription cancellation proceeds without a started payment`() = test { + val active = paymentRequestRecord(state = PaymentRequestLifecycleState.ACTIVE_RECURRING) + val canceled = paymentRequestRecord(state = PaymentRequestLifecycleState.CANCELED) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active), emptyList()) + whenever( + paykitSdkService.cancelPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ) + ).thenReturn(canceled) + sut.refresh(emptyList()).getOrThrow() + + sut.cancel(sut.subscriptions.value.single()).getOrThrow() + + verifyBlocking(paykitSdkService) { + cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + } + } + + @Test + fun `malformed expiry is rejected and unsupported payment details disable acceptance`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord(id = "malformed", expiresAt = "not-a-timestamp"), + paymentRequestRecord(id = "unsupported", endpoints = listOf("btc-unsupported-method")), + ), + ) + + sut.refresh(emptyList()).getOrThrow() + + val subscription = sut.subscriptions.value.single() + assertEquals("unsupported", subscription.paymentRequestId) + assertFalse(subscription.isProposalActionable(clock.now())) + assertEquals(listOf(subscription), sut.subscriptionProposals()) + } + + @Test + fun `presented subscription stays available without auto presenting after reactivation`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(id = "subscription")), + ) + sut.refresh(emptyList()).getOrThrow() + val subscription = sut.subscriptions.value.single() + + assertTrue(sut.markSubscriptionProposalPresented(subscription)) + assertTrue(sut.automaticSubscriptionProposals().isEmpty()) + verifyBlocking(presentationStore) { + saveSubscriptionState(eq(LOCAL_IDENTITY), argThat { presentedProposalIds == setOf(subscription.id) }) + } + + sut.clear() + whenever(presentationStore.loadSubscriptionState(LOCAL_IDENTITY)).thenReturn( + PaykitSubscriptionPresentationState(presentedProposalIds = setOf(subscription.id)), + ) + sut.activate(LOCAL_IDENTITY) + sut.refresh(emptyList()).getOrThrow() + + assertEquals(listOf(subscription), sut.subscriptionProposals()) + assertTrue(sut.automaticSubscriptionProposals().isEmpty()) + } + + @Test + fun `subscription proposal moves to expired at its deadline`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), + ) + sut.refresh(emptyList()).getOrThrow() + + advanceTimeBy(10_000) + runCurrent() + + assertEquals(PaymentRequestLifecycleState.PROPOSAL_EXPIRED, sut.subscriptions.value.single().lifecycleState) + assertTrue(sut.subscriptionProposals().isEmpty()) + } + + @Test + fun `subscription proposal moves to expired when its schedule ends`() = test { + val endingRecurrence = PaymentRequestRecurrence( + every = 1u, + unit = "month", + startsAt = "2027-01-01T08:00:00Z", + anchor = "2027-01-01T08:00:00Z", + endsAt = clock.now().plus(10.seconds).toString(), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(recurrence = endingRecurrence)), + ) + sut.refresh(emptyList()).getOrThrow() + + advanceTimeBy(10_000) + runCurrent() + + assertEquals(PaymentRequestLifecycleState.PROPOSAL_EXPIRED, sut.subscriptions.value.single().lifecycleState) + assertTrue(sut.subscriptionProposals().isEmpty()) + } + + @Test + fun `ended subscription keeps its unpaid period available`() = test { + val subscriptionId = PaykitSubscriptionId(PAYMENT_REQUEST_ID, COUNTERPARTY, PaykitReceiverPaths.SERVER) + val endingRecurrence = PaymentRequestRecurrence( + every = 1u, + unit = "month", + startsAt = "2027-01-01T08:00:00Z", + anchor = "2027-01-01T08:00:00Z", + endsAt = "2027-01-10T08:00:00Z", + ) + sut.clear() + whenever(presentationStore.loadSubscriptionState(LOCAL_IDENTITY)).thenReturn( + PaykitSubscriptionPresentationState( + acceptedAt = mapOf(subscriptionId to Instant.parse("2027-01-01T08:00:00Z")), + ), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord( + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + recurrence = endingRecurrence, + ), + ), + ) + sut.activate(LOCAL_IDENTITY) + + sut.refresh(emptyList()).getOrThrow() + + assertTrue(sut.subscriptions.value.single().isExpired(clock.now())) + assertEquals( + Instant.parse(requireNotNull(endingRecurrence.endsAt)), + sut.pendingRequests.value.single().billingPeriod?.endsAt, + ) + } + + @Suppress("LongParameterList") + private fun paymentRequestRecord( + id: String = PAYMENT_REQUEST_ID, + state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, + amount: String = "0.001", + expiresAt: String? = null, + endpoints: List = listOf(MethodId.Bolt11.rawValue), + metadata: PrivateJsonObject = METADATA, + recurrence: PaymentRequestRecurrence = this.recurrence, + paymentProofs: List = emptyList(), + ) = PaymentRequestRecord( + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + paymentRequestId = id, + localRole = PaymentRequestLocalRole.PAYER, + state = state, + proposalStreamItemId = 1uL, + proposalOutboundMessageId = null, + proposalOutboundStatus = null, + proposalEventId = "proposal-event", + terms = PaymentRequestTerms( + amount = PaymentRequestAmount(value = amount, asset = "btc"), + paymentReference = PAYMENT_REFERENCE, + proposalExpiresAt = expiresAt, + recurrence = recurrence, + acceptedPaymentEndpointIdentifiers = endpoints, + metadata = metadata, + ), + acceptedEventId = null, + acceptedOutboundStatus = null, + rejectedEventId = null, + rejectedOutboundStatus = null, + canceledEventId = null, + canceledOutboundStatus = null, + paymentProofs = paymentProofs, + lastStreamItemId = 1uL, + lastOutboundMessageId = null, + lastOutboundStatus = null, + lastEventAt = clock.now().toString(), + invalidReason = null, + ) + private fun linkedPeer( + publicKey: String, + state: LinkedPeerState, + receiverPath: String, + ) = LinkedPeerRecord( + counterparty = publicKey, + counterpartyReceiverPath = receiverPath, + state = state, + lastSyncAt = null, + lastPrivateReceiveAt = null, + failureCount = 0u, + localRecoveryAttemptId = null, + localRecoveryMarkerCreatedAt = null, + localRecoveryMarkerLastError = null, + remoteRecoveryAttemptId = null, + remoteRecoveryMarkerObservedAt = null, + ) + + private val recurrence = PaymentRequestRecurrence( + every = 1u, + unit = "month", + startsAt = "2027-01-01T08:00:00Z", + anchor = "2027-01-01T08:00:00Z", + endsAt = null, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index d68ff3a5a1..0d603bf4b7 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -5,11 +5,13 @@ package to.bitkit.repositories import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import kotlinx.coroutines.CompletableDeferred @@ -66,6 +68,9 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val paykitSdkService = mock() private val settingsStore = mock() private val presentationStore = mock() + private val paymentProofStore = mock() + private val paymentProofRepo = mock() + private val subscriptionNotificationScheduler = mock() private var schedulerOriginMillis = 0L private val clock = object : Clock { override fun now(): Instant = START_TIME.plus( @@ -83,7 +88,23 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) whenever(presentationStore.load(LOCAL_IDENTITY)).thenReturn(emptySet()) - sut = PaykitPaymentRequestRepo(testDispatcher, paykitSdkService, settingsStore, presentationStore, clock) + whenever( + presentationStore.loadSubscriptionState(any()) + ).thenReturn(PaykitSubscriptionPresentationState()) + whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptyMap()) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(any(), any())) + .thenReturn(Result.success(emptySet())) + sut = PaykitPaymentRequestRepo( + testDispatcher, + paykitSdkService, + settingsStore, + presentationStore, + paymentProofStore, + paymentProofRepo, + subscriptionNotificationScheduler, + clock, + ) sut.activate(LOCAL_IDENTITY) } @@ -175,7 +196,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.refresh(emptyList()).getOrThrow() - assertEquals(listOf("incoming"), sut.pendingRequests.value.map { it.paymentRequestId }) + assertEquals(listOf("incoming", "accepted"), sut.pendingRequests.value.map { it.paymentRequestId }) assertEquals( setOf("incoming", "accepted", "rejected", "expired", "outgoing", "unsupported"), sut.paymentRequestHistory.value.map { it.paymentRequestId }.toSet(), @@ -190,6 +211,44 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @Test + fun `completed one time payment retains its local payment rail`() = test { + val record = paymentRequestRecord() + val requestId = PaykitPaymentRequestId( + paymentRequestId = PAYMENT_REQUEST_ID, + counterparty = COUNTERPARTY, + counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + ) + whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) + .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + + sut.refresh(emptyList()).getOrThrow() + + val request = sut.paymentRequestHistory.value.single() + assertEquals(PaymentRequestLifecycleState.PROOF_SUBMITTED, request.lifecycleState) + assertEquals(PaykitPaymentProofKind.Onchain, request.paymentProofKind) + } + + @Test + fun `completed one time payment retains its SDK payment rail`() = test { + val proof = mock { + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord( + state = PaymentRequestLifecycleState.PROOF_SUBMITTED, + paymentProofs = listOf(proof), + ), + ), + ) + + sut.refresh(emptyList()).getOrThrow() + + assertEquals(PaykitPaymentProofKind.Lightning, sut.paymentRequestHistory.value.single().paymentProofKind) + } + @Test fun `pending request is removed exactly when it expires`() = test { whenever(paykitSdkService.paymentRequests()).thenReturn( @@ -520,6 +579,9 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { endpoints: List = listOf(MethodId.Bolt11.rawValue), counterparty: String = COUNTERPARTY, receiverPath: String = PaykitReceiverPaths.SERVER, + recurrence: PaymentRequestRecurrence? = null, + metadata: PrivateJsonObject = METADATA, + paymentProofs: List = emptyList(), ) = PaymentRequestRecord( counterparty = counterparty, counterpartyReceiverPath = receiverPath, @@ -534,9 +596,9 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { amount = PaymentRequestAmount(value = amount, asset = "btc"), paymentReference = PAYMENT_REFERENCE, proposalExpiresAt = expiresAt, - recurrence = null, + recurrence = recurrence, acceptedPaymentEndpointIdentifiers = endpoints, - metadata = METADATA, + metadata = metadata, ), acceptedEventId = null, acceptedOutboundStatus = null, @@ -544,7 +606,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { rejectedOutboundStatus = null, canceledEventId = null, canceledOutboundStatus = null, - paymentProofs = emptyList(), + paymentProofs = paymentProofs, lastStreamItemId = 1uL, lastOutboundMessageId = null, lastOutboundStatus = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt new file mode 100644 index 0000000000..35adfe7174 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionTest.kt @@ -0,0 +1,158 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package to.bitkit.repositories + +import com.synonym.paykit.PaymentRequestLifecycleState +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +class PaykitSubscriptionTest { + @Test + fun `monthly recurrence returns to anchor day after a short month`() { + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Month, + startsAt = Instant.parse("2027-01-31T08:00:00Z"), + anchor = Instant.parse("2027-01-31T08:00:00Z"), + endsAt = null, + ) + + val periods = recurrence.periodsThrough( + date = Instant.parse("2027-03-15T08:00:00Z"), + acceptedAt = Instant.parse("2027-01-31T08:00:00Z"), + ) + + assertEquals(2, periods.size) + assertEquals(Instant.parse("2027-02-28T08:00:00Z"), periods[0].endsAt) + assertEquals(Instant.parse("2027-03-31T08:00:00Z"), periods[1].endsAt) + } + + @Test + fun `recurrence uses the first anchor boundary after start`() { + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Month, + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + anchor = Instant.parse("2027-01-15T08:00:00Z"), + endsAt = null, + ) + + val period = recurrence.periodsThrough( + date = Instant.parse("2027-01-10T08:00:00Z"), + acceptedAt = Instant.parse("2027-01-01T08:00:00Z"), + ).first() + + assertEquals(Instant.parse("2027-01-01T08:00:00Z"), period.startsAt) + assertEquals(Instant.parse("2027-01-15T08:00:00Z"), period.endsAt) + } + + @Test + fun `recurrence returns consecutive upcoming periods`() { + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Week, + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + anchor = Instant.parse("2027-01-01T08:00:00Z"), + endsAt = null, + ) + + val periods = recurrence.upcomingPeriodsAfter( + date = Instant.parse("2027-01-02T08:00:00Z"), + limit = 3, + ) + + assertEquals( + listOf( + Instant.parse("2027-01-08T08:00:00Z"), + Instant.parse("2027-01-15T08:00:00Z"), + Instant.parse("2027-01-22T08:00:00Z"), + ), + periods.map { it.startsAt }, + ) + } + + @Test + fun `recurrence preserves nanosecond billing boundaries`() { + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Day, + startsAt = Instant.parse("2027-01-01T08:00:00.123100Z"), + anchor = Instant.parse("2027-01-01T08:00:00.123900Z"), + endsAt = null, + ) + + val period = recurrence.periodsThrough( + date = Instant.parse("2027-01-01T08:00:01Z"), + acceptedAt = Instant.parse("2027-01-01T08:00:00Z"), + ).first() + + assertEquals("2027-01-01T08:00:00.123100Z", period.sdkValue.startsAt) + assertEquals("2027-01-01T08:00:00.123900Z", period.sdkValue.endsAt) + } + + @Test + fun `recurrence does not invent period when anchor search exceeds limit`() { + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Day, + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + anchor = Instant.parse("2077-01-01T08:00:00Z"), + endsAt = null, + ) + + val periods = recurrence.periodsThrough( + date = Instant.parse("2027-01-01T08:00:00Z"), + acceptedAt = Instant.parse("2027-01-01T08:00:00Z"), + ) + + assertTrue(periods.isEmpty()) + assertFalse(recurrence.canMaterializePeriods) + } + + @Test + fun `day week month and year are supported but minute and hour are not`() { + assertTrue(PaykitRecurrenceUnit.Day.isSupported) + assertTrue(PaykitRecurrenceUnit.Week.isSupported) + assertTrue(PaykitRecurrenceUnit.Month.isSupported) + assertTrue(PaykitRecurrenceUnit.Year.isSupported) + assertFalse(PaykitRecurrenceUnit.Minute.isSupported) + assertFalse(PaykitRecurrenceUnit.Hour.isSupported) + } + + @Test + fun `subscription payment matching includes counterparty and receiver path`() { + val recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Month, + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + anchor = Instant.parse("2027-01-01T08:00:00Z"), + endsAt = null, + ) + val subscription = PaykitSubscription( + paymentRequestId = "shared", + counterparty = "counterparty-a", + counterpartyReceiverPath = "bitkit/server", + amountValue = "0.001", + amountSats = 100_000uL, + note = null, + createdAt = null, + proposalExpiresAt = null, + recurrence = recurrence, + metadata = PaykitSubscriptionMetadata(null, emptyList()), + acceptedPaymentEndpointIdentifiers = listOf("bitcoin-lightning-bolt11"), + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paidPeriods = emptyList(), + ) + val request = subscription.requestsThrough( + date = Instant.parse("2027-01-15T08:00:00Z"), + acceptedAt = Instant.parse("2027-01-01T08:00:00Z"), + ).single() + + assertTrue(request.belongsTo(subscription)) + assertFalse(request.copy(counterparty = "counterparty-b").belongsTo(subscription)) + assertFalse(request.copy(counterpartyReceiverPath = "bitkit/wallet").belongsTo(subscription)) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 0520bd1624..e1b1a84aa6 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -1193,7 +1193,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - verifyBlocking(paykitSdkService, times(4)) { + verifyBlocking(paykitSdkService, times(15)) { prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, 7uL) } } diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt new file mode 100644 index 0000000000..a9bf5f3d2b --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -0,0 +1,95 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import com.synonym.paykit.PaymentRequestLifecycleState +import org.junit.Test +import to.bitkit.R +import to.bitkit.models.NewTransactionSheetType +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionMetadata +import to.bitkit.repositories.PaykitSubscriptionRecurrence +import java.time.ZoneOffset +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +class SubscriptionsScreenTest { + private val now = Instant.parse("2027-01-15T08:00:00Z") + + @Test + fun `next transition includes the next recurring period`() { + assertEquals( + Instant.parse("2027-01-22T08:00:00Z"), + nextSubscriptionTransition(listOf(subscription(PaykitRecurrenceUnit.Week)), now, ZoneOffset.UTC), + ) + } + + @Test + fun `next transition includes the next local month`() { + assertEquals( + Instant.parse("2027-02-01T00:00:00Z"), + nextSubscriptionTransition(listOf(subscription(PaykitRecurrenceUnit.Year)), now, ZoneOffset.UTC), + ) + } + + @Test + fun `terminal open ended subscription omits timing`() { + val terminal = subscription(PaykitRecurrenceUnit.Week).copy( + lifecycleState = PaymentRequestLifecycleState.CANCELED, + ) + + assertFalse(terminal.shouldShowTiming(now)) + assertTrue(subscription(PaykitRecurrenceUnit.Week).shouldShowTiming(now)) + } + + @Test + fun `only active open ended subscriptions can be canceled`() { + val openEnded = subscription(PaykitRecurrenceUnit.Week) + val fixedEnd = openEnded.copy( + recurrence = openEnded.recurrence.copy( + endsAt = Instant.parse("2027-01-22T08:00:00Z"), + ), + ) + + assertTrue(openEnded.canCancel(now)) + assertFalse(fixedEnd.canCancel(now)) + } + + @Test + fun `subscription payment confetti follows the settled rail`() { + assertEquals( + R.raw.confetti_purple, + subscriptionConfettiResource(NewTransactionSheetType.LIGHTNING), + ) + assertEquals( + R.raw.confetti_orange, + subscriptionConfettiResource(NewTransactionSheetType.ONCHAIN), + ) + assertEquals(R.raw.confetti_purple, subscriptionConfettiResource(null)) + } + + private fun subscription(unit: PaykitRecurrenceUnit) = PaykitSubscription( + paymentRequestId = "subscription", + counterparty = "pubkypayee", + counterpartyReceiverPath = "bitkit/server", + amountValue = "0.001", + amountSats = 100_000u, + note = "Subscription", + createdAt = Instant.parse("2027-01-01T08:00:00Z"), + proposalExpiresAt = null, + recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = unit, + startsAt = Instant.parse("2027-01-01T08:00:00Z"), + anchor = Instant.parse("2027-01-01T08:00:00Z"), + endsAt = null, + ), + metadata = PaykitSubscriptionMetadata(description = null, benefits = emptyList()), + acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paidPeriods = emptyList(), + ) +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2582d443fe..cd9bb3a2f9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -12,6 +12,7 @@ import app.cash.turbine.test import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner +import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -34,6 +35,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any @@ -61,6 +63,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.ext.toSendFailureDetails import to.bitkit.models.BalanceState import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails @@ -85,6 +88,8 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.PaykitBillingPeriod +import to.bitkit.repositories.PaykitOnchainPaymentProofResolution import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PaykitPaymentRequest @@ -93,6 +98,11 @@ import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionId +import to.bitkit.repositories.PaykitSubscriptionMetadata +import to.bitkit.repositories.PaykitSubscriptionRecurrence import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution @@ -117,6 +127,7 @@ import to.bitkit.services.NodeServiceFgState import to.bitkit.test.BaseUnitTest import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SubscriptionRoute import to.bitkit.ui.components.TimedSheetType import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute @@ -137,6 +148,7 @@ import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime +import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @RunWith(RobolectricTestRunner::class) @@ -194,6 +206,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val pubkyContactsLoadVersion = MutableStateFlow(0L) private val pendingPaykitPaymentRequests = MutableStateFlow>(emptyList()) private val paykitPaymentRequestHistory = MutableStateFlow>(emptyList()) + private val paykitSubscriptions = MutableStateFlow>(emptyList()) + private val onchainPaymentResolution = MutableStateFlow(null) private val surfacedPaykitPaymentRequestIds = mutableSetOf() private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -254,6 +268,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) whenever(paykitPaymentRequestRepo.paymentRequestHistory).thenReturn(paykitPaymentRequestHistory) + whenever(paykitPaymentRequestRepo.subscriptions).thenReturn(paykitSubscriptions) + whenever(paykitPaymentRequestRepo.automaticSubscriptionProposals()).thenReturn(emptyList()) whenever(paykitPaymentRequestRepo.eligibleTargets).thenReturn(MutableStateFlow(emptyList())) whenever(paykitPaymentRequestRepo.isCreatingRequest).thenReturn(MutableStateFlow(false)) whenever(paykitPaymentRequestRepo.automaticPendingRequests()).thenAnswer { @@ -269,8 +285,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) + whenever(paykitPaymentProofRepo.onchainPaymentResolution).thenReturn(onchainPaymentResolution) whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { activityRepo.setContact(any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.initialLinkBurstStarted).thenReturn(MutableSharedFlow()) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) @@ -480,7 +498,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `manually reopened request waits for a newer private list and then opens`() = test { + fun `manually reopened request preserves tags while waiting for a newer private list`() = test { sut.setIsAuthenticated(true) val request = paymentRequest() val bolt11 = "lnbcrt1updatedmanualrequest" @@ -503,7 +521,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() sut.showPaymentRequests() - sut.openIncomingPaymentRequest(request.id) + sut.openIncomingPaymentRequestWithTags(request.id, listOf("Lunch")) advanceTimeBy(TRANSITION_SCREEN_MS) runCurrent() @@ -515,9 +533,129 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + assertEquals(listOf("Lunch"), sut.sendUiState.value.selectedTags) verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) } + @Test + fun `due subscription request opens after the subscription sheet closes`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest().copy( + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2026-08-24T00:00:00Z"), + endsAt = Instant.parse("2026-08-31T00:00:00Z"), + ) + ) + val bolt11 = "lnbcrt1duesubscriptionrequest" + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + ), + ), + ) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request) + surfacedPaykitPaymentRequestIds += request.id + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + + val subscriptionId = PaykitSubscriptionId( + request.paymentRequestId, + request.counterparty, + request.counterpartyReceiverPath, + ) + sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscriptionId))) + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + assertTrue(sut.sendUiState.value.isSubscriptionPayment) + verify(privatePaykitRepo).beginPaymentRequest(request) + } + + @Test + fun `subscription notification targets its exact billing period`() = test { + sut.setIsAuthenticated(true) + val otherRequest = paymentRequest().copy(paymentRequestId = "other-subscription") + val targetRequest = paymentRequest().copy( + paymentRequestId = "target-subscription", + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2026-08-25T12:00:00Z"), + endsAt = Instant.parse("2026-09-01T12:00:00Z"), + ), + ) + whenever(paykitPaymentRequestRepo.refresh(emptyList())).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.beginPaymentRequest(targetRequest)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + ) + pendingPaykitPaymentRequests.value = listOf(otherRequest, targetRequest) + surfacedPaykitPaymentRequestIds += otherRequest.id + surfacedPaykitPaymentRequestIds += targetRequest.id + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(privatePaykitRepo) + val pendingProposal = mock() + whenever(paykitPaymentRequestRepo.automaticSubscriptionProposals()).thenReturn(listOf(pendingProposal)) + + sut.onPaykitSubscriptionNotificationTapped(testPublicKey, targetRequest.id) + runCurrent() + + verify(privatePaykitRepo).beginPaymentRequest(targetRequest) + verify(privatePaykitRepo, never()).beginPaymentRequest(otherRequest) + } + + @Test + fun `subscription notification target survives initial identity activation`() = test { + sut.setIsAuthenticated(true) + val otherRequest = paymentRequest().copy(paymentRequestId = "other-subscription") + val targetRequest = paymentRequest().copy( + paymentRequestId = "target-subscription", + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2026-08-25T12:00:00Z"), + endsAt = Instant.parse("2026-09-01T12:00:00Z"), + ), + ) + whenever(paykitPaymentRequestRepo.refresh(emptyList())).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.beginPaymentRequest(targetRequest)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + ) + pendingPaykitPaymentRequests.value = listOf(otherRequest, targetRequest) + surfacedPaykitPaymentRequestIds += otherRequest.id + surfacedPaykitPaymentRequestIds += targetRequest.id + isPaykitEnabled.value = true + + sut.onPaykitSubscriptionNotificationTapped(testPublicKey, targetRequest.id) + runCurrent() + pubkyContactsLoadVersion.value = 1L + pubkyPublicKey.value = testPublicKey + runCurrent() + + verify(privatePaykitRepo).beginPaymentRequest(targetRequest) + verify(privatePaykitRepo, never()).beginPaymentRequest(otherRequest) + } + + @Test + fun `subscription notification for another identity is ignored`() = test { + val targetRequest = paymentRequest().copy( + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2026-08-25T12:00:00Z"), + endsAt = Instant.parse("2026-09-01T12:00:00Z"), + ), + ) + pubkyPublicKey.value = testPublicKey + + sut.onPaykitSubscriptionNotificationTapped("pubky${"a".repeat(52)}", targetRequest.id) + + verify(privatePaykitRepo, never()).beginPaymentRequest(targetRequest) + } + @Test fun `failed manual request presentation returns to the request queue`() = test { sut.setIsAuthenticated(true) @@ -2907,7 +3045,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) } @@ -2921,16 +3059,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever { - lightningRepo.sendOnChain( - address = address, - sats = request.amountSats, - speed = TransactionSpeed.Medium, - utxosToSpend = null, - isMaxAmount = false, - tags = emptyList(), - ) - }.thenReturn(Result.success("txid")) + stubSuccessfulOnchainSend(address, request.amountSats) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -2951,12 +3080,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentRequestRepo).accept(request) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(lightningRepo).sendOnChain( - address = address, - sats = request.amountSats, - speed = TransactionSpeed.Medium, - utxosToSpend = null, - isMaxAmount = false, - tags = emptyList(), + address = any(), + sats = any(), + speed = anyOrNull(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) } @@ -3007,16 +3141,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val contactKey = "pubkycontact" val privateContext = PrivatePaykitPaymentContext("bitkit/wallet", 7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever { - lightningRepo.sendOnChain( - address = address, - sats = 1000u, - speed = TransactionSpeed.Medium, - utxosToSpend = null, - isMaxAmount = false, - tags = emptyList(), - ) - }.thenReturn(Result.success("txid")) + stubSuccessfulOnchainSend(address, 1000u) whenever(privatePaykitRepo.consumePrivatePaymentList(contactKey, privateContext)) .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(contactKey, privateContext) @@ -3043,16 +3168,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever { - lightningRepo.sendOnChain( - address = address, - sats = request.amountSats, - speed = TransactionSpeed.Medium, - utxosToSpend = null, - isMaxAmount = false, - tags = emptyList(), - ) - }.thenReturn(Result.success("txid")) + stubSuccessfulOnchainSend(address, request.amountSats) setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -3156,6 +3272,396 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `paid recurring request refreshes subscription state immediately`() = test { + val address = "bcrt1qrecurringpaymentrequest" + val request = paymentRequest().copy( + billingPeriod = PaykitBillingPeriod( + startsAt = Instant.parse("2026-08-24T00:00:00Z"), + endsAt = Instant.parse("2026-08-31T00:00:00Z"), + ) + ) + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + pubkyPublicKey.value = testPublicKey + enablePaykitUi() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.refresh(emptyList())).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + stubSuccessfulOnchainSend(address, request.amountSats) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = address, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentRequestRepo).refresh(emptyList()) + } + + @Test + fun `initial subscription payment auto start is consumed once`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + setSendState( + SendUiState( + amount = 1_000u, + isInitialSubscriptionPayment = true, + initialSubscriptionPaymentAutoStartPending = true, + ) + ) + + sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) + advanceUntilIdle() + + assertFalse(sut.sendUiState.value.initialSubscriptionPaymentAutoStartPending) + assertTrue(sut.sendUiState.value.shouldConfirmPay) + + sut.setSendEvent(SendEvent.ClearPayConfirmation) + advanceUntilIdle() + sut.setSendEvent(SendEvent.StartInitialSubscriptionPayment) + advanceUntilIdle() + + assertFalse(sut.sendUiState.value.shouldConfirmPay) + } + + @Test + fun `subscription acceptance pays a due period materialized during acceptance`() = test { + val subscription = subscriptionStartingAt(Clock.System.now() + 60.seconds) + val dueRequest = paymentRequest().copy( + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + billingPeriod = PaykitBillingPeriod( + startsAt = Clock.System.now(), + endsAt = Clock.System.now() + 60.seconds, + ), + ) + paykitSubscriptions.value = listOf(subscription) + assertNull(subscription.paymentDueOnAcceptance(Clock.System.now())) + whenever(paykitPaymentRequestRepo.accept(subscription)).thenReturn(Result.success(dueRequest)) + whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(dueRequest)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + ) + sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscription.id))) + advanceUntilIdle() + + val startedPayment = sut.acceptSubscriptionAndStartPayment(subscription).getOrThrow() + + assertTrue(startedPayment) + verify(privatePaykitRepo).beginPaymentRequestWaitingForUpdatedList(dueRequest) + assertTrue(sut.currentSheet.value is Sheet.Send) + assertTrue(sut.sendUiState.value.isInitialSubscriptionPayment) + assertEquals(dueRequest.id, sut.sendUiState.value.incomingPaymentRequestId) + assertFalse(sut.isAcceptingSubscription.value) + } + + @Test + fun `initial subscription send replaces review without hiding the sheet`() = test { + val subscription = subscriptionStartingAt(Clock.System.now()) + val destination = Sheet.Send(SendRoute.Confirm) + sut.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscription.id))) + advanceUntilIdle() + setSendState(SendUiState(isInitialSubscriptionPayment = true)) + + sut.showSheet(destination) + runCurrent() + + assertEquals(destination, sut.currentSheet.value) + } + + @Test + fun `initial subscription retry keeps the send sheet presented`() = test { + val request = paymentRequest() + pendingPaykitPaymentRequests.value = listOf(request) + whenever(paykitPaymentRequestRepo.refresh(emptyList())).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + ) + val sendSheet = Sheet.Send( + SendRoute.errorFromFailure( + IllegalStateException("failed").toSendFailureDetails(context, paymentRequest = null) + ) + ) + setSendState( + SendUiState( + isPaymentRequest = true, + isSubscriptionPayment = true, + isInitialSubscriptionPayment = true, + incomingPaymentRequestId = request.id, + ) + ) + sut.showSheet(sendSheet) + advanceUntilIdle() + + sut.retryIncomingPaymentRequest(request.id) + runCurrent() + + assertTrue(sut.currentSheet.value is Sheet.Send) + advanceUntilIdle() + assertTrue(sut.currentSheet.value is Sheet.Send) + assertFalse(sut.isRetryingInitialSubscriptionPayment.value) + } + + @Test + fun `canceling initial subscription payment opens retry screen`() = test { + val request = paymentRequest() + setActiveContactPaymentContext( + testPublicKey, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + ) + setSendState( + SendUiState( + isPaymentRequest = true, + isInitialSubscriptionPayment = true, + incomingPaymentRequestId = request.id, + ) + ) + + sut.sendEffect.test { + sut.setSendEvent(SendEvent.CancelInitialSubscriptionPayment) + assertTrue(awaitItem() is SendEffect.NavigateToError) + } + } + + @Test + fun `onchain payment failure before send attempt cancels prepared proof`() = test { + val request = paymentRequest() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + stubOnchainSend( + address = "bcrt1qpreflightfailure", + sats = request.amountSats, + result = Result.failure(IllegalStateException("preflight failed")), + invokeBeforeSendAttempt = false, + ) + setActiveContactPaymentContext( + testPublicKey, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + ) + setSendState( + SendUiState( + address = "bcrt1qpreflightfailure", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + isInitialSubscriptionPayment = true, + ) + ) + + sut.sendEffect.test { + confirmCurrentPayment() + + assertTrue(awaitItem() is SendEffect.NavigateToError) + } + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + } + + @Test + fun `definite onchain failure after send attempt allows proof retry`() = test { + val request = paymentRequest() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + stubOnchainSend( + address = "bcrt1qdefinitefailure", + sats = request.amountSats, + result = Result.failure(NodeException.InvalidAddress("invalid address")), + ) + setActiveContactPaymentContext( + testPublicKey, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + ) + setSendState( + SendUiState( + address = "bcrt1qdefinitefailure", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + isInitialSubscriptionPayment = true, + ) + ) + + sut.sendEffect.test { + confirmCurrentPayment() + + assertTrue(awaitItem() is SendEffect.NavigateToError) + } + verify(paykitPaymentProofRepo).failOnchainPayment(request) + } + + @Test + fun `uncertain onchain failure resolves the matching pending payment`() = test { + val request = paymentRequest() + pubkyPublicKey.value = testPublicKey + runCurrent() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + stubOnchainSend( + address = "bcrt1quncertainfailure", + sats = request.amountSats, + result = Result.failure(IllegalStateException("outcome unknown")), + ) + setActiveContactPaymentContext( + testPublicKey, + incomingPaymentRequest = request, + isInitialSubscriptionPayment = true, + ) + setSendState( + SendUiState( + address = "bcrt1quncertainfailure", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + isInitialSubscriptionPayment = true, + incomingPaymentRequestId = request.id, + ) + ) + + sut.sendEffect.test { + confirmCurrentPayment() + + val pendingRoute = SendRoute.Pending( + request.paymentRequestId, + request.amountSats.toLong(), + observeResolution = false, + ) + assertEquals( + SendEffect.NavigateToPending(pendingRoute.paymentHash, pendingRoute.amount, false), + awaitItem(), + ) + sut.showSheet(Sheet.Send(pendingRoute)) + + val transactionId = "ab".repeat(32) + onchainPaymentResolution.value = PaykitOnchainPaymentProofResolution( + testPublicKey, + request.id, + transactionId, + ) + assertEquals(SendEffect.PaymentSuccess, awaitItem()) + runCurrent() + assertEquals(transactionId, sut.successSendUiState.value.paymentHashOrTxId) + assertFalse(sut.successSendUiState.value.isLoadingDetails) + } + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + } + + @Test + fun `cold onchain proof resolution restores contact correlation without opening success`() = test { + val request = paymentRequest() + val transactionId = "ef".repeat(32) + pubkyPublicKey.value = testPublicKey + runCurrent() + + onchainPaymentResolution.value = PaykitOnchainPaymentProofResolution( + testPublicKey, + request.id, + transactionId, + ) + runCurrent() + + verify(paykitPaymentProofRepo).consumeOnchainPaymentResolution(any()) + verify(activityRepo).setContact( + contactPublicKey = request.counterparty, + forPaymentId = transactionId, + syncLdkPayments = false, + ) + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `unrelated uncertain onchain resolution does not hijack another send`() = test { + val request = paymentRequest() + pubkyPublicKey.value = testPublicKey + runCurrent() + val replacementRequest = paymentRequest().copy(paymentRequestId = "replacement-request") + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + stubOnchainSend( + address = "bcrt1quncertainreplacement", + sats = request.amountSats, + result = Result.failure(IllegalStateException("outcome unknown")), + ) + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + setSendState( + SendUiState( + address = "bcrt1quncertainreplacement", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + incomingPaymentRequestId = request.id, + ) + ) + + sut.sendEffect.test { + confirmCurrentPayment() + awaitItem() + + setSendState( + SendUiState( + address = "bcrt1qreplacement", + amount = replacementRequest.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + incomingPaymentRequestId = replacementRequest.id, + ) + ) + sut.showSheet(Sheet.Send(SendRoute.Confirm)) + onchainPaymentResolution.value = PaykitOnchainPaymentProofResolution( + testPublicKey, + request.id, + "cd".repeat(32), + ) + runCurrent() + + expectNoEvents() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + } + + @Test + fun `post broadcast bookkeeping failure still completes payment proof`() = test { + val request = paymentRequest() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + stubOnchainSend( + address = "bcrt1qbookkeepingfailure", + sats = request.amountSats, + result = Result.failure(IllegalStateException("activity persistence failed")), + broadcastTxId = "broadcast-txid", + ) + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + setSendState( + SendUiState( + address = "bcrt1qbookkeepingfailure", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ) + ) + + confirmCurrentPayment() + + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + } + @Test fun `incoming payment request is not accepted when private list consumption fails`() = test { val address = "bcrt1qpaymentrequest" @@ -3177,7 +3683,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(lightningRepo, never()).sendOnChain( address = any(), sats = any(), @@ -3188,6 +3694,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = any(), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) } @@ -3209,7 +3717,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) } @@ -3231,7 +3739,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -3243,6 +3751,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = any(), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) } @@ -3264,7 +3774,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) } @@ -3288,7 +3798,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -3300,6 +3810,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = any(), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) } @@ -3307,16 +3819,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `non-contact onchain payment does not discard private endpoint`() = test { val address = "bcrt1qpublicpayment" balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever { - lightningRepo.sendOnChain( - address = address, - sats = 1000u, - speed = TransactionSpeed.Medium, - utxosToSpend = null, - isMaxAmount = false, - tags = emptyList(), - ) - }.thenReturn(Result.success("txid")) + stubSuccessfulOnchainSend(address, 1000u) setSendState( SendUiState( address = address, @@ -3638,6 +4141,40 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } + private suspend fun stubSuccessfulOnchainSend(address: String, sats: ULong, txId: String = "txid") { + stubOnchainSend(address, sats, Result.success(txId), broadcastTxId = txId) + } + + private suspend fun stubOnchainSend( + address: String, + sats: ULong, + result: Result, + invokeBeforeSendAttempt: Boolean = true, + broadcastTxId: String? = null, + ) { + whenever { + lightningRepo.sendOnChain( + address = any(), + sats = any(), + speed = anyOrNull(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), + ) + }.doSuspendableAnswer { invocation -> + kotlin.check(invocation.getArgument(0) == address) + kotlin.check(invocation.getArgument(1).toULong() == sats) + if (invokeBeforeSendAttempt) invocation.getArgument Unit>(9)() + if (broadcastTxId != null) invocation.getArgument Unit>(10)(broadcastTxId) + result + } + } + private fun enableQuickPay(thresholdSats: ULong) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) @@ -3771,10 +4308,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { publicKey: String, privatePaymentContext: PrivatePaykitPaymentContext? = null, incomingPaymentRequest: PaykitPaymentRequest? = null, + isInitialSubscriptionPayment: Boolean = false, ) { val field = AppViewModel::class.java.getDeclaredField("activeContactPaymentContext") field.isAccessible = true - field.set(sut, ContactPaymentContext(publicKey, privatePaymentContext, incomingPaymentRequest)) + field.set( + sut, + ContactPaymentContext( + publicKey, + privatePaymentContext, + incomingPaymentRequest, + isInitialSubscriptionPayment, + ), + ) } private fun activeContactPaymentContext(): ContactPaymentContext? { @@ -3838,6 +4384,28 @@ class AppViewModelSendFlowTest : BaseUnitTest() { acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue, MethodId.P2wpkh.rawValue), ) + private fun subscriptionStartingAt(startsAt: Instant) = PaykitSubscription( + paymentRequestId = "subscription-id", + counterparty = testPublicKey, + counterpartyReceiverPath = "bitkit/server", + amountValue = "0.000025", + amountSats = 2_500uL, + note = "Weekly coffee", + createdAt = Clock.System.now(), + proposalExpiresAt = null, + recurrence = PaykitSubscriptionRecurrence( + every = 1, + unit = PaykitRecurrenceUnit.Week, + startsAt = startsAt, + anchor = startsAt, + endsAt = null, + ), + metadata = PaykitSubscriptionMetadata(description = null, benefits = emptyList()), + acceptedPaymentEndpointIdentifiers = listOf(MethodId.Bolt11.rawValue), + lifecycleState = PaymentRequestLifecycleState.PROPOSED, + paidPeriods = emptyList(), + ) + private fun paymentRequestCreation( request: PaykitPaymentRequest, wasPublishedToActiveState: Boolean = true, diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index a1f0f7d56c..7d6c36c230 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -447,6 +447,8 @@ class TransferViewModelTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = eq(true), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) } @@ -481,6 +483,8 @@ class TransferViewModelTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = eq(false), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -492,6 +496,8 @@ class TransferViewModelTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = eq(true), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) } @@ -521,6 +527,8 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), any(), any(), + any(), + any(), ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) @@ -538,6 +546,8 @@ class TransferViewModelTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = eq(false), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -549,6 +559,8 @@ class TransferViewModelTest : BaseUnitTest() { channelId = anyOrNull(), isMaxAmount = eq(true), tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -1744,6 +1756,8 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), any(), any(), + any(), + any(), ), ).thenReturn(Result.success(TXID)) } diff --git a/changelog.d/next/1186.added.md b/changelog.d/next/1186.added.md new file mode 100644 index 0000000000..71b66fce6d --- /dev/null +++ b/changelog.d/next/1186.added.md @@ -0,0 +1 @@ +Bitkit can now review, manage, and pay recurring payment requests from Paykit contacts.