diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt new file mode 100644 index 0000000000..bc242a2993 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreenTest.kt @@ -0,0 +1,180 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.unit.dp +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.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionMetadata +import to.bitkit.repositories.PaykitSubscriptionRecurrence +import to.bitkit.repositories.PaykitSubscriptionRole +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.screens.paymentrequests.PaymentRequestExpiration +import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@ComposeUi +class CreateSubscriptionScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun detailsRequiresAmountNameAndCompletedIconLoad() { + var amount by mutableStateOf(0uL) + var name by mutableStateOf("") + var frequency by mutableStateOf(PaykitRecurrenceUnit.Month) + var isLoadingIcon by mutableStateOf(false) + var choseRecipient = false + composeTestRule.setContent { + AppThemeSurface { + CreateSubscriptionDetails( + amountSats = amount, + name = name, + description = "Monthly support", + frequency = frequency, + selectedIconUri = null, + isLoadingIcon = isLoadingIcon, + onAmountClick = {}, + onNameChange = { name = it }, + onDescriptionChange = {}, + onFrequencyChange = { frequency = it }, + onIconSelected = {}, + onChooseRecipient = { choseRecipient = true }, + ) + } + } + + composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsNotEnabled() + composeTestRule.onNodeWithTag("SubscriptionName").performTextInput("Support") + composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsNotEnabled() + composeTestRule.runOnIdle { amount = 1000uL } + composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsEnabled() + listOf(PaykitRecurrenceUnit.Day, PaykitRecurrenceUnit.Week, PaykitRecurrenceUnit.Month, PaykitRecurrenceUnit.Year) + .forEach { option -> + composeTestRule.onNodeWithTag("Tab-${option.name.lowercase()}").performClick() + assertEquals(option, frequency) + } + composeTestRule.runOnIdle { isLoadingIcon = true } + composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").assertIsNotEnabled() + composeTestRule.runOnIdle { isLoadingIcon = false } + composeTestRule.onNodeWithTag("SubscriptionChooseRecipient").performClick() + assertTrue(choseRecipient) + } + + @Test + fun recipientAllowsExactlyOneSelectionAndChangesExpiry() { + val second = PaykitPaymentRequestTarget("pubky" + "z".repeat(52), "bitkit/wallet") + var selected by mutableStateOf(null) + var expiration by mutableStateOf(PaymentRequestExpiration.Week) + var proposedTo: PaykitPaymentRequestTarget? = null + composeTestRule.setContent { + AppThemeSurface { + SubscriptionRecipient( + targets = persistentListOf(target, second), + contacts = persistentListOf(contact, PubkyProfile.forDisplay(second.publicKey, "Bob", null)), + selectedTarget = selected, + expiration = expiration, + isCreating = false, + isLoadingIcon = false, + onBack = {}, + onPaste = { "" }, + onSelected = { selected = it }, + onExpirationChange = { expiration = it }, + onPropose = { proposedTo = selected }, + ) + } + } + + composeTestRule.onNodeWithTag("SubscriptionPropose").assertIsNotEnabled() + composeTestRule.onNodeWithTag("SubscriptionContact${target.publicKey}").performClick().assertIsSelected() + composeTestRule.onNodeWithTag("SubscriptionContact${second.publicKey}").performClick().assertIsSelected() + composeTestRule.onNodeWithTag("SubscriptionContact${target.publicKey}").assertIsNotSelected() + composeTestRule.onNodeWithTag("SubscriptionExpiration").performClick() + composeTestRule.onNodeWithText("1 hour").performClick() + assertEquals(PaymentRequestExpiration.Hour, expiration) + composeTestRule.onNodeWithTag("SubscriptionPropose").performClick() + assertEquals(second, proposedTo) + } + + @Test + fun confirmationNeverClaimsQueuedProposalWasSent() { + var delivery by mutableStateOf(PaykitPaymentRequestDeliveryStatus.Queued) + composeTestRule.setContent { + AppThemeSurface { + Box(Modifier.height(520.dp)) { + SubscriptionProposalSent( + subscription.copy(deliveryStatus = delivery, note = "Long subscription name ".repeat(7)), + contact, + onDone = {}, + ) + } + } + } + + composeTestRule.onNodeWithText("Queued").assertIsDisplayed() + composeTestRule.onNodeWithText("OK").assertIsDisplayed() + composeTestRule.onNodeWithTag("SubscriptionConfirmationBody").performScrollToNode(hasText("Anna")) + composeTestRule.onNodeWithText("Anna").assertIsDisplayed() + composeTestRule.onAllNodesWithText("Sent", substring = true, ignoreCase = true).assertCountEquals(0) + composeTestRule.runOnIdle { delivery = PaykitPaymentRequestDeliveryStatus.Sent } + composeTestRule.onNodeWithText("Sent").assertIsDisplayed() + composeTestRule.onNodeWithTag("SubscriptionConfirmationBody") + .performScrollToNode(hasText("You have sent a subscription proposal to")) + composeTestRule.onNodeWithText("You have sent a subscription proposal to").assertIsDisplayed() + composeTestRule.onNodeWithTag("SubscriptionConfirmationBody") + .performScrollToNode(hasText("Monthly subscription")) + composeTestRule.onNodeWithText("Monthly subscription").assertIsDisplayed() + composeTestRule.onAllNodesWithText("Queued", substring = true, ignoreCase = true).assertCountEquals(0) + composeTestRule.onNodeWithText("OK").assertIsDisplayed() + } + + private val target = PaykitPaymentRequestTarget("pubky" + "y".repeat(52), "bitkit/wallet") + private val contact = PubkyProfile.forDisplay(target.publicKey, "Anna", null) + private val startsAt = Instant.parse("2027-01-15T08:00:00Z") + private val subscription = PaykitSubscription( + paymentRequestId = "creator-proposal", + counterparty = target.publicKey, + counterpartyReceiverPath = target.receiverPath, + amountValue = "0.00001", + amountSats = 1000uL, + note = "Support", + createdAt = startsAt, + proposalExpiresAt = Instant.parse("2027-01-22T08:00:00Z"), + recurrence = PaykitSubscriptionRecurrence(1, PaykitRecurrenceUnit.Month, startsAt, startsAt, null), + metadata = PaykitSubscriptionMetadata(description = null, benefits = emptyList()), + acceptedPaymentEndpointIdentifiers = emptyList(), + role = PaykitSubscriptionRole.Payee, + lifecycleState = PaymentRequestLifecycleState.PROPOSED, + paidPeriods = emptyList(), + ) +} 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 index f1f92218a9..37d00e00f9 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -2,9 +2,12 @@ package to.bitkit.ui.screens.subscriptions +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf @@ -22,6 +25,33 @@ class SubscriptionsScreenTest { @get:Rule val composeTestRule = createComposeRule() + @Test + fun emptyOverviewOffersCreationWithoutDiscover() { + var requestedCreation = false + composeTestRule.setContent { + AppThemeSurface { + SubscriptionsContent( + subscriptions = persistentListOf(), + contacts = persistentListOf(), + acceptedAt = { null }, + now = Instant.parse("2027-01-15T08:00:00Z"), + onBack = {}, + initialTab = SubscriptionTab.Overview, + pendingPaymentRequestCount = 0, + onSubscription = {}, + onCreateSubscription = { requestedCreation = true }, + paymentsContent = {}, + ) + } + } + + composeTestRule.onNodeWithText("At the moment, you don’t have any active subscriptions from any providers.") + .assertIsDisplayed() + composeTestRule.onAllNodesWithText("Discover").assertCountEquals(0) + composeTestRule.onNodeWithTag("SubscriptionCreate").assertIsDisplayed().performClick() + assertTrue(requestedCreation) + } + @Test fun paymentsTabShowsEligibleOneTimeRequestAction() { var requestedPayment = false @@ -36,6 +66,7 @@ class SubscriptionsScreenTest { initialTab = SubscriptionTab.Payments, pendingPaymentRequestCount = 0, onSubscription = {}, + onCreateSubscription = {}, paymentsContent = { PaymentRequestsContent( requests = persistentListOf(), diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 7ceb500cf5..7976b769a9 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -25,6 +25,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull @@ -41,10 +43,12 @@ import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.satsToMsat import to.bitkit.services.PaykitPaymentRequestProposalTerms +import to.bitkit.services.PaykitPaymentRequestRecurrenceTerms import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.Logger +import to.bitkit.utils.SubscriptionIcon import java.math.BigDecimal import java.util.UUID import java.util.concurrent.atomic.AtomicLong @@ -130,6 +134,21 @@ data class PaykitPaymentRequestCreation( val wasPublishedToActiveState: Boolean, ) +class PaykitSubscriptionDraft( + val amountSats: ULong, + val name: String, + val description: String, + val frequency: PaykitRecurrenceUnit, + val expiresAt: Instant, + val iconBytes: ByteArray? = null, +) + +data class PaykitSubscriptionCreation( + val subscription: PaykitSubscription, + val creatorIdentity: String, + val wasPublishedToActiveState: Boolean, +) + private data class PaykitPaymentRequestTargetContext( val savedPublicKeys: List, val linkedReceiverPaths: Map>, @@ -144,6 +163,7 @@ sealed class PaykitPaymentRequestError(message: String) : AppError(message) { data object RequestUnavailable : PaykitPaymentRequestError("Payment request is unavailable") data object RequestExpired : PaykitPaymentRequestError("Payment request has expired") data object OperationInProgress : PaykitPaymentRequestError("Payment request operation is already in progress") + data object SubscriptionTooLong : PaykitPaymentRequestError("Subscription proposal exceeds the message size limit") } @Suppress("TooManyFunctions", "LongParameterList", "LargeClass") @@ -223,7 +243,7 @@ class PaykitPaymentRequestRepo @Inject constructor( _pendingRequests.value.filterNot { it.id in presentedRequestIds } fun subscriptionProposals(): List = - _subscriptions.value.filter { it.isProposalVisible(clock.now()) } + _subscriptions.value.filter { it.isPayer && it.isProposalVisible(clock.now()) } fun automaticSubscriptionProposals(): List = subscriptionProposals().filterNot { it.id in presentedSubscriptionProposalIds } @@ -258,7 +278,7 @@ class PaykitPaymentRequestRepo @Inject constructor( ): Boolean = withContext(ioDispatcher) { operationMutex.withLock { val current = _subscriptions.value.firstOrNull { it.id == subscription.id } - ?.takeIf { it.isProposalVisible(clock.now()) } + ?.takeIf { it.isPayer && it.isProposalVisible(clock.now()) } ?: return@withLock false if (current.id in presentedSubscriptionProposalIds) return@withLock true val identity = activeIdentity ?: return@withLock false @@ -421,6 +441,171 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + suspend fun proposeSubscription( + draft: PaykitSubscriptionDraft, + target: PaykitPaymentRequestTarget, + savedPublicKeys: List, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (!creationMutex.tryLock()) throw PaykitPaymentRequestError.OperationInProgress + _isCreatingRequest.update { true } + try { + operationMutex.withLock { + createSubscriptionLocked(draft, target, savedPublicKeys) + } + } finally { + _isCreatingRequest.update { false } + creationMutex.unlock() + } + }.onFailure { + Logger.warn("Failed to create Paykit subscription proposal", it, context = TAG) + } + } + + private suspend fun createSubscriptionLocked( + draft: PaykitSubscriptionDraft, + target: PaykitPaymentRequestTarget, + savedPublicKeys: List, + ): PaykitSubscriptionCreation { + val generation = stateGeneration.get() + val expectedIdentity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val validationDate = clock.now() + val name = draft.name.trim() + val description = draft.description.trim() + validateSubscriptionDraft(draft, name, validationDate) + + val endpoints = subscriptionProposalEndpoints(target, savedPublicKeys, expectedIdentity) + val reservedIconUri = PaykitSubscriptionProposal.reservedIconUri.takeIf { draft.iconBytes != null } + val preflight = buildSubscriptionProposal(draft, name, description, reservedIconUri, endpoints, validationDate) + PaykitSubscriptionProposal.validate(preflight) + val iconUri = draft.iconBytes?.let { + paykitSdkService.uploadProfileAvatar( + bytes = SubscriptionIcon.compress(it), + contentType = "image/jpeg", + expectedIdentity = expectedIdentity, + ) + } + val proposalDate = clock.now() + validateProposalExpiration(draft.expiresAt, proposalDate) + val proposal = buildSubscriptionProposal(draft, name, description, iconUri, endpoints, proposalDate) + PaykitSubscriptionProposal.validate(proposal) + val record = paykitSdkService.proposePaymentRequest( + counterparty = target.publicKey, + counterpartyReceiverPath = target.receiverPath, + proposal = proposal, + expectedIdentity = expectedIdentity, + ) + val deliveryStatus = if (proposalWasSent(record, processPendingMessages())) { + PaykitPaymentRequestDeliveryStatus.Sent + } else { + PaykitPaymentRequestDeliveryStatus.Queued + } + val subscription = record.toPaykitSubscription(deliveryStatus) + ?: throw PaykitPaymentRequestError.RequestUnavailable + val wasPublished = publishCreatedSubscription(subscription, generation, expectedIdentity) + return PaykitSubscriptionCreation(subscription, expectedIdentity, wasPublished) + } + + private suspend fun validateSubscriptionDraft( + draft: PaykitSubscriptionDraft, + name: String, + proposalDate: Instant, + ) { + val hasValidContent = draft.amountSats != 0uL && name.isNotEmpty() + val canPropose = draft.frequency.isSupported && isAvailable() + if (!hasValidContent || !canPropose) { + throw PaykitPaymentRequestError.RequestUnavailable + } + validateProposalExpiration(draft.expiresAt, proposalDate) + } + + private fun validateProposalExpiration(expiresAt: Instant, proposalDate: Instant) { + if (expiresAt <= proposalDate) throw PaykitPaymentRequestError.RequestExpired + } + + private suspend fun subscriptionProposalEndpoints( + target: PaykitPaymentRequestTarget, + savedPublicKeys: List, + expectedIdentity: String, + ): List { + val selectedSavedKey = savedPublicKeys.firstOrNull { + PubkyPublicKeyFormat.matches(it, target.publicKey) + } + val targets = selectedSavedKey?.let { savedKey -> + targetContext(listOf(savedKey), expectedIdentity)?.let { eligibleTargets(it).targets } + } + .orEmpty() + val settings = settingsStore.data.first() + val endpoints = acceptedPaymentEndpointIdentifiers(settings) + if (target !in targets || !settings.sharesPrivatePaykitEndpoints || endpoints.isEmpty()) { + throw PaykitPaymentRequestError.RequestUnavailable + } + return endpoints + } + + private fun buildSubscriptionProposal( + draft: PaykitSubscriptionDraft, + name: String, + description: String, + iconUri: String?, + endpointIdentifiers: List, + proposalDate: Instant, + ): PaykitPaymentRequestProposalTerms { + val subscriptionMetadata = mutableMapOf( + "version" to JsonPrimitive(1), + "description" to JsonPrimitive(description), + "benefits" to JsonArray(emptyList()), + ) + iconUri?.let { subscriptionMetadata["icon_uri"] = JsonPrimitive(it) } + val timestamp = Instant.fromEpochSeconds(proposalDate.epochSeconds).toString() + return PaykitPaymentRequestProposalTerms( + amountValue = draft.amountSats.toBitcoinAmount(), + paymentReference = "bitkit-${UUID.randomUUID()}", + proposalExpiresAt = draft.expiresAt.toString(), + acceptedPaymentEndpointIdentifiers = endpointIdentifiers, + metadataJson = JsonObject( + mapOf( + "note" to JsonPrimitive(name), + "subscription" to JsonObject(subscriptionMetadata), + ) + ).toString(), + recurrence = PaykitPaymentRequestRecurrenceTerms( + every = 1u, + unit = draft.frequency.rawValue, + startsAt = timestamp, + anchor = timestamp, + ), + ) + } + + private fun proposalWasSent( + record: PaymentRequestRecord, + reports: List, + ): Boolean { + if (record.proposalOutboundStatus == OutboundPrivateMessageStatus.SENT) return true + val messageId = record.proposalOutboundMessageId ?: return false + return reports.any { + PubkyPublicKeyFormat.matches(it.counterparty, record.counterparty) && + it.counterpartyReceiverPath == record.counterpartyReceiverPath && + messageId in it.report?.sent.orEmpty() + } + } + + private fun publishCreatedSubscription( + subscription: PaykitSubscription, + generation: Long, + expectedIdentity: String, + ): Boolean { + val isCurrent = isCurrentState(generation, expectedIdentity) + if (isCurrent) { + _subscriptions.update { subscriptions -> + subscriptions.filterNot { it.id == subscription.id } + subscription + } + scheduleExpirationLocked() + } + return isCurrent + } + private fun publishCreatedRequest( request: PaykitPaymentRequest, generation: Long, @@ -502,7 +687,7 @@ class PaykitPaymentRequestRepo @Inject constructor( 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) } + ?.takeIf { it == subscription && it.isPayer && it.isProposalActionable(validationDate) } ?: throw PaykitPaymentRequestError.RequestUnavailable val record = paykitSdkService.acceptPaymentRequest( current.counterparty, @@ -523,14 +708,16 @@ class PaykitPaymentRequestRepo @Inject constructor( } 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 + if (!it.canCancel(clock.now())) throw PaykitPaymentRequestError.RequestUnavailable + if (it.isPayer) { + 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) } @@ -577,7 +764,8 @@ class PaykitPaymentRequestRepo @Inject constructor( .map { it.withExpiredLifecycle(now) } val restoredAcceptances = subscriptions .filter { - it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING || it.paidPeriods.isNotEmpty() + it.isPayer && + (it.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING || it.paidPeriods.isNotEmpty()) } .filterNot { it.id in subscriptionAcceptedAt } .associate { subscription -> @@ -587,7 +775,7 @@ class PaykitPaymentRequestRepo @Inject constructor( subscription.id to acceptedAt } val updatedSubscriptionAcceptedAt = subscriptionAcceptedAt + restoredAcceptances - val recurringRequestsBySubscription = subscriptions.associateWith { subscription -> + val recurringRequestsBySubscription = subscriptions.filter { it.isPayer }.associateWith { subscription -> updatedSubscriptionAcceptedAt[subscription.id] ?.let { subscription.requestsThrough(now, it) } .orEmpty() @@ -816,6 +1004,19 @@ class PaykitPaymentRequestRepo @Inject constructor( subscriptions.filterNot { it.id == subscription.id } + subscription } + if (!subscription.isPayer) { + 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, + ) + scheduleExpirationLocked() + return + } + val recurringRequests = requestsThroughAcceptance(subscription, now) val unpaidRequests = if (subscription.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING) { recurringRequests.filter { it.lifecycleState != PaymentRequestLifecycleState.PROOF_SUBMITTED } @@ -903,7 +1104,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun prunePresentedSubscriptionProposalIds(subscriptions: List) { val proposalIds = subscriptions - .filter { it.isProposalVisible(clock.now()) } + .filter { it.isPayer && it.isProposalVisible(clock.now()) } .mapTo(mutableSetOf()) { it.id } val prunedIds = presentedSubscriptionProposalIds.intersect(proposalIds) if (prunedIds == presentedSubscriptionProposalIds) return diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index ff103cff4c..414a63c52d 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -3,6 +3,8 @@ package to.bitkit.repositories import com.synonym.paykit.BillingPeriod +import com.synonym.paykit.OutboundPrivateMessageStatus +import com.synonym.paykit.PaymentProofRecord import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord @@ -157,8 +159,11 @@ data class PaykitSubscriptionRecurrence( data class PaykitSubscriptionMetadata( val description: String?, val benefits: List, + val iconUri: String? = null, ) +enum class PaykitSubscriptionRole { Payer, Payee } + @Serializable data class PaykitSubscriptionId( val paymentRequestId: String, @@ -178,6 +183,8 @@ data class PaykitSubscription( val recurrence: PaykitSubscriptionRecurrence, val metadata: PaykitSubscriptionMetadata, val acceptedPaymentEndpointIdentifiers: List, + val role: PaykitSubscriptionRole = PaykitSubscriptionRole.Payer, + val deliveryStatus: PaykitPaymentRequestDeliveryStatus? = null, val lifecycleState: PaymentRequestLifecycleState, val paidPeriods: List, val paymentProofKinds: Map = emptyMap(), @@ -185,6 +192,12 @@ data class PaykitSubscription( val id: PaykitSubscriptionId get() = PaykitSubscriptionId(paymentRequestId, counterparty, counterpartyReceiverPath) + val isPayer: Boolean + get() = role == PaykitSubscriptionRole.Payer + + val isCreatedByUser: Boolean + get() = role == PaykitSubscriptionRole.Payee + fun isProposalVisible(now: Instant): Boolean = lifecycleState == PaymentRequestLifecycleState.PROPOSED && proposalExpiresAt?.let { it > now } != false && @@ -199,6 +212,15 @@ data class PaykitSubscription( fun isActive(now: Instant): Boolean = lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING && recurrence.endsAt?.let { it > now } != false + fun isCreatedVisible(now: Instant): Boolean = + isCreatedByUser && (isProposalVisible(now) || isActive(now) || paidPeriods.isNotEmpty()) + + fun canCancel(now: Instant): Boolean = recurrence.endsAt == null && if (isCreatedByUser) { + isProposalVisible(now) || isActive(now) + } else { + isActive(now) + } + fun isExpired(now: Instant): Boolean = lifecycleState in setOf( PaymentRequestLifecycleState.CANCELED, PaymentRequestLifecycleState.REJECTED, @@ -214,8 +236,9 @@ data class PaykitSubscription( else -> this } - fun requestsThrough(date: Instant, acceptedAt: Instant): List = - recurrence.periodsThrough(date, acceptedAt).map { period -> + fun requestsThrough(date: Instant, acceptedAt: Instant): List { + if (!isPayer) return emptyList() + return recurrence.periodsThrough(date, acceptedAt).map { period -> PaykitPaymentRequest( paymentRequestId = paymentRequestId, counterparty = counterparty, @@ -235,13 +258,37 @@ data class PaykitSubscription( paymentProofKind = paymentProofKinds[period], ) } + } fun paymentDueOnAcceptance(now: Instant): PaykitPaymentRequest? = requestsThrough(now, now).firstOrNull() + + fun receivedPaymentRequests(): List { + if (!isCreatedByUser) return emptyList() + return paidPeriods.map { period -> + PaykitPaymentRequest( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + amountValue = amountValue, + amountSats = amountSats, + note = note, + createdAt = period.startsAt, + expiresAt = null, + acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers, + direction = PaykitPaymentRequestDirection.Outgoing, + lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + billingPeriod = period, + paymentProofKind = paymentProofKinds[period], + ) + } + } } @Suppress("CyclomaticComplexMethod", "ReturnCount") -internal fun PaymentRequestRecord.toPaykitSubscription(): PaykitSubscription? { - if (localRole != PaymentRequestLocalRole.PAYER) return null +internal fun PaymentRequestRecord.toPaykitSubscription( + deliveryStatusOverride: PaykitPaymentRequestDeliveryStatus? = null, +): PaykitSubscription? { + val role = localRole.toSubscriptionRole() ?: return null val requestTerms = terms ?: return null val sdkRecurrence = requestTerms.recurrence ?: return null if ( @@ -266,14 +313,14 @@ internal fun PaymentRequestRecord.toPaykitSubscription(): PaykitSubscription? { .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) - } + val recurrence = PaykitSubscriptionRecurrence( + every = sdkRecurrence.every.toInt(), + unit = recurrenceUnit, + startsAt = startsAt, + anchor = anchor, + endsAt = recurrenceEndsAt, + ) + val payments = paymentProofs.mapNotNull { it.toSubscriptionPayment(recurrence) } return PaykitSubscription( paymentRequestId = paymentRequestId, counterparty = counterparty, @@ -283,33 +330,63 @@ internal fun PaymentRequestRecord.toPaykitSubscription(): PaykitSubscription? { 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, - ), + recurrence = recurrence, metadata = metadataObject, acceptedPaymentEndpointIdentifiers = endpoints, + role = role, + deliveryStatus = subscriptionDeliveryStatus(role, deliveryStatusOverride), lifecycleState = state, - paidPeriods = payments.map { it.first }, + paidPeriods = payments.map { it.first }.distinct(), paymentProofKinds = payments.mapNotNull { (period, kind) -> kind?.let { period to it } }.toMap(), ) } +@Suppress("ReturnCount") +private fun PaymentProofRecord.toSubscriptionPayment( + recurrence: PaykitSubscriptionRecurrence, +): Pair? { + val period = billingPeriod ?: return null + val start = period.startsAt.parseInstant() ?: return null + val end = period.endsAt.parseInstant() ?: return null + val parsedPeriod = PaykitBillingPeriod(start, end).takeIf { start < end } ?: return null + if (recurrence.periodsThrough(date = start, acceptedAt = start).firstOrNull() != parsedPeriod) return null + return parsedPeriod to PaykitPaymentProofKind.fromPaymentEndpointIdentifier(paymentEndpointIdentifier) +} + +private fun PaymentRequestLocalRole?.toSubscriptionRole(): PaykitSubscriptionRole? = when (this) { + PaymentRequestLocalRole.PAYER -> PaykitSubscriptionRole.Payer + PaymentRequestLocalRole.PAYEE -> PaykitSubscriptionRole.Payee + PaymentRequestLocalRole.UNKNOWN, null -> null +} + +private fun PaymentRequestRecord.subscriptionDeliveryStatus( + role: PaykitSubscriptionRole, + override: PaykitPaymentRequestDeliveryStatus?, +): PaykitPaymentRequestDeliveryStatus? { + if (role != PaykitSubscriptionRole.Payee) return null + if (override != null) return override + return if (proposalOutboundStatus == OutboundPrivateMessageStatus.SENT) { + PaykitPaymentRequestDeliveryStatus.Sent + } else { + PaykitPaymentRequestDeliveryStatus.Queued + } +} + private fun PrivateJsonObject.subscriptionMetadata(): PaykitSubscriptionMetadata = runCatching { val subscription = Json.parseToJsonElement(exportText()).jsonObject["subscription"]?.jsonObject - ?: return@runCatching PaykitSubscriptionMetadata(null, emptyList()) + ?: return@runCatching PaykitSubscriptionMetadata(null, emptyList(), null) if (subscription["version"]?.jsonPrimitive?.contentOrNull != "1") { - return@runCatching PaykitSubscriptionMetadata(null, emptyList()) + return@runCatching PaykitSubscriptionMetadata(null, emptyList(), null) } 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())) + val iconUri = subscription["icon_uri"]?.jsonPrimitive?.contentOrNull + ?.clean(512) + ?.takeIf { it.startsWith("pubky://") } + PaykitSubscriptionMetadata(description, benefits, iconUri) +}.getOrDefault(PaykitSubscriptionMetadata(null, emptyList(), null)) private fun String.clean(limit: Int): String? = trim().take(limit).takeIf(String::isNotEmpty) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt index 9d04672f16..63d371cae2 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionNotificationScheduler.kt @@ -67,7 +67,8 @@ class PaykitSubscriptionNotificationScheduler @Inject constructor( val now = clock.now() val scheduledWork = subscriptions .filter { - it.isActive(now) && + it.isPayer && + it.isActive(now) && it.recurrence.unit.isSupported && acceptedAt(it) != null } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt new file mode 100644 index 0000000000..e7cf4342bc --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscriptionProposal.kt @@ -0,0 +1,53 @@ +package to.bitkit.repositories + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import to.bitkit.services.PaykitPaymentRequestProposalTerms + +internal object PaykitSubscriptionProposal { + /** Maximum plaintext size accepted by Paykit's pubky-noise transport. */ + const val MAX_MESSAGE_BYTES = 1000 + + /** Longest JPEG avatar URI produced by Bitkit's staging profile namespace. */ + val reservedIconUri = "pubky://" + "x".repeat(122) + + fun validate(terms: PaykitPaymentRequestProposalTerms) { + if (encodedSize(terms) > MAX_MESSAGE_BYTES) throw PaykitPaymentRequestError.SubscriptionTooLong + } + + fun encodedSize(terms: PaykitPaymentRequestProposalTerms): Int { + val recurrence = requireNotNull(terms.recurrence) + val uuid = "00000000-0000-0000-0000-000000000000" + val wire = buildJsonObject { + put("version", 1) + put("kind", "paykit.payment_request") + put("event_id", uuid) + put("payment_request_id", uuid) + putJsonObject("request") { + putJsonObject("amount") { + put("value", terms.amountValue) + put("asset", "btc") + } + put("payment_reference", terms.paymentReference) + put("proposal_expires_at", terms.proposalExpiresAt) + putJsonObject("recurrence") { + put("every", recurrence.every.toLong()) + put("unit", recurrence.unit) + put("starts_at", recurrence.startsAt) + put("anchor", recurrence.anchor) + put("ends_at", recurrence.endsAt?.let(::JsonPrimitive) ?: JsonNull) + } + putJsonArray("accepted_payment_endpoint_identifiers") { + terms.acceptedPaymentEndpointIdentifiers.forEach { add(JsonPrimitive(it)) } + } + put("metadata", Json.parseToJsonElement(terms.metadataJson)) + } + } + return wire.toString().encodeToByteArray().size + } +} diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 141fbd569b..1dbef142f8 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -26,6 +26,7 @@ import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestFilter import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestRecurrence import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PaymentTarget import com.synonym.paykit.PrivateContactPaymentResolution @@ -123,6 +124,15 @@ data class PaykitPaymentRequestProposalTerms( val proposalExpiresAt: String, val acceptedPaymentEndpointIdentifiers: List, val metadataJson: String, + val recurrence: PaykitPaymentRequestRecurrenceTerms? = null, +) + +data class PaykitPaymentRequestRecurrenceTerms( + val every: UInt, + val unit: String, + val startsAt: String, + val anchor: String, + val endsAt: String? = null, ) data class PaykitPrivateReceiverPathSelection( @@ -376,9 +386,16 @@ class PaykitSdkService @Inject constructor( } } - suspend fun uploadProfileAvatar(bytes: ByteArray, contentType: String): String { + suspend fun uploadProfileAvatar(bytes: ByteArray, contentType: String, expectedIdentity: String? = null): String { isSetup.await() return operationMutex.withLock { + if (expectedIdentity != null) { + val identityStatus = handle().identityStatus() + check( + identityStatus?.liveSessionAvailable == true && + PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) + ) { "Paykit identity changed before uploading the subscription icon" } + } handle().uploadProfileAvatar(bytes, contentType).uri.also { notifyBackupStateChanged() } @@ -642,7 +659,15 @@ class PaykitSdkService @Inject constructor( amount = PaymentRequestAmount(proposal.amountValue, PaykitIssuerInterop.BITCOIN_ASSET), paymentReference = PaymentReference(proposal.paymentReference), proposalExpiresAt = proposal.proposalExpiresAt, - recurrence = null, + recurrence = proposal.recurrence?.let { + PaymentRequestRecurrence( + every = it.every, + unit = it.unit, + startsAt = it.startsAt, + anchor = it.anchor, + endsAt = it.endsAt, + ) + }, acceptedPaymentEndpointIdentifiers = proposal.acceptedPaymentEndpointIdentifiers, metadata = PrivateJsonObject(proposal.metadataJson), ) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d903bebf26..694361b326 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -120,6 +120,7 @@ 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.CreateSubscriptionSheet import to.bitkit.ui.screens.subscriptions.SubscriptionDetailScreen import to.bitkit.ui.screens.subscriptions.SubscriptionSheet import to.bitkit.ui.screens.subscriptions.SubscriptionsScreen @@ -555,6 +556,8 @@ fun ContentView( }, ) + Sheet.CreateSubscription -> CreateSubscriptionSheet(appViewModel) + is Sheet.Subscription -> SubscriptionSheet(appViewModel, sheet.route) is Sheet.ActivityDateRangeSelector -> DateRangeSelectorSheet() @@ -778,6 +781,7 @@ private fun RootNavHost( onRequestPayment = { appViewModel.showSheet(Sheet.Receive(route = ReceiveRoute.PaymentRequestRecipient)) }, + onCreateSubscription = appViewModel::showSubscriptionCreator, onDetails = { navController.navigateTo( Routes.SubscriptionDetail( 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 00443e067c..3f019a3183 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -28,6 +28,7 @@ import java.math.BigDecimal @Composable fun MoneyDisplay( sats: Long, + modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, showSymbol: Boolean? = null, ) { @@ -35,7 +36,7 @@ fun MoneyDisplay( text?.let { Display( text = it.withAccent(accentColor = Colors.White64), - modifier = Modifier + modifier = modifier .clickableAlpha(onClick = onClick) .testTag("MoneyText") ) @@ -95,8 +96,9 @@ fun MoneyMSB( unit: PrimaryDisplay = LocalCurrencies.current.primaryDisplay, color: Color = MaterialTheme.colorScheme.primary, accent: Color = Colors.White64, + showSymbol: Boolean = unit == PrimaryDisplay.FIAT, ) { - rememberMoneyText(sats = sats, unit = unit)?.let { text -> + rememberMoneyText(sats = sats, unit = unit, showSymbol = showSymbol)?.let { text -> BodyMSB( text = text.withAccent(accentColor = accent), color = color, diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt index f2e837add0..ddb83c7426 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt @@ -56,16 +56,16 @@ fun PubkyContactRow( ) { PubkyContactAvatar(profile = profile) Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier.weight(1f) ) { - BodyS( + Caption13Up( text = profile.truncatedPublicKey, color = Colors.White64, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - BodySSB( + BodyMSB( text = profile.name, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt index 3e59c5ba95..9a31f4046c 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt @@ -22,6 +22,7 @@ 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.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -38,6 +39,7 @@ fun PubkyImage( uri: String, size: Dp, modifier: Modifier = Modifier, + shape: Shape = CircleShape, ) { var imageState by remember { mutableStateOf(ImageState.Loading) } @@ -54,7 +56,7 @@ fun PubkyImage( contentAlignment = Alignment.Center, modifier = modifier .size(size) - .clip(CircleShape) + .clip(shape) ) { AsyncImage( model = uri, @@ -108,7 +110,7 @@ private fun ImageOverlay(state: ImageState, size: Dp) { modifier = Modifier .fillMaxSize() .graphicsLayer { alpha = errorAlpha } - .background(Colors.Gray5, CircleShape) + .background(Colors.Gray5) ) { Icon( painter = painterResource(R.drawable.ic_user_square), 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 69c896ac3c..1208e0ac21 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -73,6 +73,7 @@ sealed interface Sheet { val presentationId: String = UUID.randomUUID().toString(), ) : Sheet data object PaymentRequests : Sheet + data object CreateSubscription : Sheet data class Subscription(val route: SubscriptionRoute) : Sheet data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet data object ChangePin : Sheet 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 e28a15d763..0a54dd3e57 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,6 +3,7 @@ package to.bitkit.ui.screens.paymentrequests +import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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 @@ -53,6 +55,7 @@ import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth @@ -386,6 +389,11 @@ internal fun PaymentRequestRecipientContent( onBack: () -> Unit, onPaste: () -> String, onSelected: (PaykitPaymentRequestTarget) -> Unit, + @StringRes titleRes: Int = R.string.wallet__payment_request_choose_recipient, + selectedTarget: PaykitPaymentRequestTarget? = null, + testTagPrefix: String = "PaymentRequest", + action: (@Composable () -> Unit)? = null, + footer: @Composable () -> Unit = {}, ) { var query by remember { mutableStateOf("") } @@ -406,10 +414,11 @@ internal fun PaymentRequestRecipientContent( .gradientBackground() .navigationBarsPadding() .padding(horizontal = 16.dp) - .testTag("PaymentRequestRecipient") + .testTag("${testTagPrefix}Recipient") ) { SheetTopBar( - titleText = stringResource(R.string.wallet__payment_request_choose_recipient), + titleText = stringResource(titleRes), + action = action, onBack = onBack, ) Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) @@ -427,25 +436,26 @@ internal fun PaymentRequestRecipientContent( .clickableAlpha { query = PubkyPublicKeyFormat.bounded(onPaste()) } - .padding(horizontal = 12.dp) - .testTag("PaymentRequestRecipientPaste"), + .padding(horizontal = 24.dp) + .testTag("${testTagPrefix}RecipientPaste") ) { Icon( painter = painterResource(R.drawable.ic_clipboard_text), contentDescription = null, tint = Colors.White, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(16.dp), ) - BodyMSB(text = stringResource(R.string.wallet__payment_request_paste)) + CaptionB(text = stringResource(R.string.wallet__payment_request_paste)) } }, modifier = Modifier .fillMaxWidth() - .testTag("PaymentRequestRecipientSearch"), + .height(52.dp) + .testTag("${testTagPrefix}RecipientSearch") ) - VerticalSpacer(24.dp) + VerticalSpacer(32.dp) Caption13Up(text = stringResource(R.string.contacts__contacts_header), color = Colors.White64) - VerticalSpacer(8.dp) + VerticalSpacer(16.dp) HorizontalDivider(color = Colors.White10) LazyColumn(modifier = Modifier.weight(1f)) { if (recipients.isEmpty()) { @@ -463,7 +473,7 @@ internal fun PaymentRequestRecipientContent( modifier = Modifier .fillMaxWidth() .padding(top = 24.dp) - .testTag("PaymentRequestRecipientUnavailable"), + .testTag("${testTagPrefix}RecipientUnavailable"), ) } } @@ -474,12 +484,15 @@ internal fun PaymentRequestRecipientContent( PubkyContactRow( profile = contact, onClick = { onSelected(target) }, - verticalPadding = 16.dp, - modifier = Modifier.testTag("PaymentRequestContact${contact.publicKey}"), + verticalPadding = 24.dp, + isSelected = selectedTarget?.let { it == target }, + selectionColor = Colors.Brand, + modifier = Modifier.testTag("${testTagPrefix}Contact${contact.publicKey}"), ) HorizontalDivider(color = Colors.White10) } } + footer() } } @@ -560,7 +573,7 @@ internal fun PaymentRequestSentContent( } @Composable -private fun PaymentRequestExpiration.title(): String = stringResource( +internal fun PaymentRequestExpiration.title(): String = stringResource( when (this) { PaymentRequestExpiration.Hour -> R.string.wallet__payment_request_expiry_hour PaymentRequestExpiration.Day -> R.string.wallet__payment_request_expiry_day diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt new file mode 100644 index 0000000000..f98a81ffbc --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/CreateSubscriptionScreen.kt @@ -0,0 +1,555 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.subscriptions + +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +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.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.draw.clip +import androidx.compose.ui.layout.ContentScale +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import to.bitkit.R +import to.bitkit.ext.getClipboardText +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.Toast +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestDraft +import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.repositories.PaykitRecurrenceUnit +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionDraft +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.MoneyDisplay +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactRow +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.paymentrequests.PaymentRequestAmountContent +import to.bitkit.ui.screens.paymentrequests.PaymentRequestExpiration +import to.bitkit.ui.screens.paymentrequests.PaymentRequestRecipientContent +import to.bitkit.ui.screens.paymentrequests.title +import to.bitkit.ui.screens.wallets.activity.components.CustomTabRowWithSpacing +import to.bitkit.ui.screens.wallets.activity.components.TabItem +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.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.utils.SubscriptionIcon +import to.bitkit.viewmodels.AmountInputViewModel +import to.bitkit.viewmodels.AppViewModel +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@Composable +fun CreateSubscriptionSheet( + appViewModel: AppViewModel, + amountInputViewModel: AmountInputViewModel = hiltViewModel(key = "SubscriptionAmount"), +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() + val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + var step by remember { mutableStateOf(SubscriptionCreationStep.Details) } + var amountSats by remember { mutableStateOf(0uL) } + var name by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var frequency by remember { mutableStateOf(PaykitRecurrenceUnit.Month) } + var expiration by remember { mutableStateOf(PaymentRequestExpiration.Week) } + var selectedTarget by remember { mutableStateOf(null) } + var selectedIconUri by remember { mutableStateOf(null) } + var iconBytes by remember { mutableStateOf(null) } + var isLoadingIcon by remember { mutableStateOf(false) } + var createdSubscription by remember { mutableStateOf(null) } + + when (step) { + SubscriptionCreationStep.Details -> CreateSubscriptionDetails( + amountSats = amountSats, + name = name, + description = description, + frequency = frequency, + selectedIconUri = selectedIconUri, + isLoadingIcon = isLoadingIcon, + onAmountClick = { step = SubscriptionCreationStep.Amount }, + onNameChange = { name = it.take(256) }, + onDescriptionChange = { description = it.take(1024) }, + onFrequencyChange = { frequency = it }, + onIconSelected = { uri -> + isLoadingIcon = true + scope.launch { + val bytes = withContext(Dispatchers.IO) { + runSuspendCatching { SubscriptionIcon.load(context.contentResolver, uri) } + } + bytes.onSuccess { + iconBytes = it + selectedIconUri = uri + }.onFailure { + appViewModel.toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.subscriptions__icon_error), + ) + } + isLoadingIcon = false + } + }, + onChooseRecipient = { step = SubscriptionCreationStep.Recipient }, + ) + SubscriptionCreationStep.Amount -> PaymentRequestAmountContent( + amountInputViewModel = amountInputViewModel, + initialDraft = PaykitPaymentRequestDraft( + amountSats = amountSats, + note = name, + expiresAt = Clock.System.now() + expiration.duration, + ), + contact = null, + onBack = { step = SubscriptionCreationStep.Details }, + onContinue = { + amountSats = it.amountSats + step = SubscriptionCreationStep.Details + }, + modifier = Modifier.sheetHeight() + ) + SubscriptionCreationStep.Recipient -> SubscriptionRecipient( + targets = targets.toImmutableList(), + contacts = contacts.toImmutableList(), + selectedTarget = selectedTarget, + expiration = expiration, + isCreating = isCreating, + isLoadingIcon = isLoadingIcon, + onBack = { step = SubscriptionCreationStep.Details }, + onPaste = { context.getClipboardText()?.trim().orEmpty() }, + onSelected = { selectedTarget = it }, + onExpirationChange = { expiration = it }, + onPropose = { + selectedTarget?.let { target -> + appViewModel.createSubscription( + draft = PaykitSubscriptionDraft( + amountSats = amountSats, + name = name, + description = description, + frequency = frequency, + expiresAt = Clock.System.now() + expiration.duration, + iconBytes = iconBytes, + ), + target = target, + ) { + createdSubscription = it + step = SubscriptionCreationStep.Sent + } + } + }, + ) + SubscriptionCreationStep.Sent -> createdSubscription?.let { + SubscriptionProposalSent( + subscription = it, + contact = contacts.firstOrNull { contact -> + PubkyPublicKeyFormat.matches(contact.publicKey, it.counterparty) + } ?: PubkyProfile.placeholder(it.counterparty), + onDone = appViewModel::hideSheet, + ) + } + } +} + +@Composable +internal fun CreateSubscriptionDetails( + amountSats: ULong, + name: String, + description: String, + frequency: PaykitRecurrenceUnit, + selectedIconUri: Uri?, + isLoadingIcon: Boolean, + onAmountClick: () -> Unit, + onNameChange: (String) -> Unit, + onDescriptionChange: (String) -> Unit, + onFrequencyChange: (PaykitRecurrenceUnit) -> Unit, + onIconSelected: (Uri) -> Unit, + onChooseRecipient: () -> Unit, + modifier: Modifier = Modifier, +) { + val pickMedia = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { + it?.let(onIconSelected) + } + val gallery = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { + it?.let(onIconSelected) + } + val launchPhotoPicker = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } else { + gallery.launch("image/*") + } + } + + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("CreateSubscription") + ) { + SheetTopBar(titleText = stringResource(R.string.subscriptions__create_subscription)) + LazyColumn(modifier = Modifier.weight(1f)) { + item { + Caption13Up(text = stringResource(R.string.wallet__payment_request_amount), color = Colors.White64) + VerticalSpacer(12.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().clickableAlpha(onClick = onAmountClick) + ) { + MoneyDisplay( + sats = amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + showSymbol = true, + modifier = Modifier.weight(1f, fill = false) + ) + Icon( + painter = painterResource(R.drawable.ic_pencil_simple), + contentDescription = stringResource(R.string.common__edit), + tint = Colors.White, + modifier = Modifier.size(24.dp) + ) + } + VerticalSpacer(24.dp) + Caption13Up(text = stringResource(R.string.subscriptions__frequency), color = Colors.White64) + VerticalSpacer(8.dp) + SubscriptionFrequencyPicker(frequency, onFrequencyChange) + VerticalSpacer(24.dp) + Caption13Up(text = stringResource(R.string.subscriptions__name), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = name, + onValueChange = onNameChange, + placeholder = stringResource(R.string.subscriptions__name_placeholder), + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("SubscriptionName") + ) + VerticalSpacer(24.dp) + Caption13Up(text = stringResource(R.string.subscriptions__description), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = description, + onValueChange = onDescriptionChange, + placeholder = stringResource(R.string.subscriptions__description_placeholder), + minLines = 3, + maxLines = 3, + modifier = Modifier.fillMaxWidth().testTag("SubscriptionDescription") + ) + VerticalSpacer(24.dp) + Caption13Up(text = stringResource(R.string.subscriptions__custom_icon), color = Colors.White64) + VerticalSpacer(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.White10) + .clickable(enabled = !isLoadingIcon, onClick = launchPhotoPicker) + .padding(16.dp) + .testTag("SubscriptionIconPicker") + ) { + if (selectedIconUri == null) { + SubscriptionDefaultIcon(size = 40.dp) + } else { + AsyncImage( + model = selectedIconUri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)) + ) + } + CaptionB( + text = stringResource(R.string.subscriptions__custom_icon_description), + color = Colors.White64, + modifier = Modifier.padding(start = 16.dp).weight(1f) + ) + } + VerticalSpacer(24.dp) + } + } + PrimaryButton( + text = stringResource(R.string.subscriptions__choose_recipient), + onClick = onChooseRecipient, + enabled = amountSats > 0uL && name.isNotBlank() && !isLoadingIcon, + modifier = Modifier.testTag("SubscriptionChooseRecipient") + ) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun SubscriptionFrequencyPicker( + selected: PaykitRecurrenceUnit, + onSelected: (PaykitRecurrenceUnit) -> Unit, +) { + CustomTabRowWithSpacing( + tabs = creationFrequencies, + currentTabIndex = creationFrequencies.indexOfFirst { it.unit == selected }, + onTabChange = { onSelected(it.unit) }, + selectedColor = Colors.White, + ) +} + +@Composable +internal fun SubscriptionRecipient( + targets: ImmutableList, + contacts: ImmutableList, + selectedTarget: PaykitPaymentRequestTarget?, + expiration: PaymentRequestExpiration, + isCreating: Boolean, + isLoadingIcon: Boolean, + onBack: () -> Unit, + onPaste: () -> String, + onSelected: (PaykitPaymentRequestTarget) -> Unit, + onExpirationChange: (PaymentRequestExpiration) -> Unit, + onPropose: () -> Unit, +) { + PaymentRequestRecipientContent( + targets = targets, + contacts = contacts, + onBack = onBack, + onPaste = onPaste, + onSelected = onSelected, + titleRes = R.string.subscriptions__choose_recipient, + selectedTarget = selectedTarget, + testTagPrefix = "Subscription", + action = { SubscriptionExpirationMenu(expiration, onExpirationChange) }, + footer = { + PrimaryButton( + text = stringResource(R.string.subscriptions__propose_subscription), + onClick = onPropose, + isLoading = isCreating, + enabled = selectedTarget != null && !isLoadingIcon, + modifier = Modifier.testTag("SubscriptionPropose") + ) + VerticalSpacer(16.dp) + }, + modifier = Modifier.sheetHeight() + ) +} + +@Composable +private fun SubscriptionExpirationMenu( + expiration: PaymentRequestExpiration, + onExpirationChange: (PaymentRequestExpiration) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + IconButton( + onClick = { expanded = true }, + modifier = Modifier.testTag("SubscriptionExpiration") + ) { + Icon( + painter = painterResource(R.drawable.ic_timer_outline), + contentDescription = stringResource(R.string.wallet__payment_request_expires), + tint = Colors.White, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + PaymentRequestExpiration.entries.forEach { option -> + DropdownMenuItem( + text = { BodyM(option.title()) }, + onClick = { + onExpirationChange(option) + expanded = false + }, + trailingIcon = if (option == expiration) { + { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = null, + tint = Colors.Purple, + ) + } + } else { + null + }, + ) + } + } + } +} + +@Composable +internal fun SubscriptionProposalSent( + subscription: PaykitSubscription, + contact: PubkyProfile, + onDone: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("SubscriptionProposalSent") + ) { + SheetTopBar( + titleText = stringResource( + if (subscription.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + R.string.wallet__payment_request_sent_title + } else { + R.string.subscriptions__proposal_queued_title + } + ), + ) + BoxWithConstraints(modifier = Modifier.weight(1f)) { + Column( + verticalArrangement = Arrangement.Bottom, + modifier = Modifier.verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight).testTag("SubscriptionConfirmationBody") + ) { + Image( + painter = painterResource(R.drawable.check), + contentDescription = null, + modifier = Modifier.size(256.dp).align(Alignment.CenterHorizontally) + ) + Display( + text = stringResource( + if (subscription.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + R.string.subscriptions__proposal_sent_headline + } else { + R.string.subscriptions__proposal_queued_headline + } + ).withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(8.dp) + BodyM( + text = stringResource( + if (subscription.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + R.string.subscriptions__proposal_sent_description + } else { + R.string.subscriptions__proposal_queued_description + } + ), + color = Colors.White64, + ) + VerticalSpacer(16.dp) + PubkyContactRow( + profile = contact, + onClick = {}, + verticalPadding = 16.dp, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .padding(horizontal = 16.dp) + ) + VerticalSpacer(16.dp) + SubscriptionRow( + subscription = subscription, + contact = contact, + faded = false, + subtitle = subscription.subscriptionFrequencyText(), + ) + VerticalSpacer(24.dp) + } + } + PrimaryButton( + text = stringResource(R.string.common__ok), + onClick = onDone, + ) + VerticalSpacer(16.dp) + } +} + +@Composable +private fun PaykitRecurrenceUnit.creationTitle(): String = stringResource( + when (this) { + PaykitRecurrenceUnit.Day -> R.string.subscriptions__daily + PaykitRecurrenceUnit.Week -> R.string.subscriptions__weekly + PaykitRecurrenceUnit.Month -> R.string.subscriptions__monthly + PaykitRecurrenceUnit.Year -> R.string.subscriptions__yearly + PaykitRecurrenceUnit.Minute, PaykitRecurrenceUnit.Hour -> R.string.subscriptions__unsupported_frequency + } +) + +private enum class SubscriptionCreationStep { Details, Amount, Recipient, Sent } + +private enum class SubscriptionFrequencyOption(val unit: PaykitRecurrenceUnit) : TabItem { + Day(PaykitRecurrenceUnit.Day), + Week(PaykitRecurrenceUnit.Week), + Month(PaykitRecurrenceUnit.Month), + Year(PaykitRecurrenceUnit.Year); + + override val uiText: String + @Composable get() = unit.creationTitle() +} + +private val creationFrequencies = SubscriptionFrequencyOption.entries.toImmutableList() + +@Preview(showSystemUi = true) +@Composable +private fun CreateSubscriptionDetailsPreview() { + AppThemeSurface { + BottomSheetPreview { + CreateSubscriptionDetails( + amountSats = 100_000uL, + name = "Rent Support", + description = "Monthly support", + frequency = PaykitRecurrenceUnit.Month, + selectedIconUri = null, + isLoadingIcon = false, + onAmountClick = {}, + onNameChange = {}, + onDescriptionChange = {}, + onFrequencyChange = {}, + onIconSelected = {}, + onChooseRecipient = {}, + modifier = Modifier.sheetHeight() + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionRow.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionRow.kt new file mode 100644 index 0000000000..82b9491ce0 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionRow.kt @@ -0,0 +1,92 @@ +package to.bitkit.ui.screens.subscriptions + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PaykitSubscription +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.MoneyCell +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.PubkyImage +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors + +@Composable +internal fun SubscriptionRow( + subscription: PaykitSubscription, + contact: PubkyProfile, + subtitle: String, + faded: Boolean, + onClick: (() -> Unit)? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .alpha(if (faded) 0.5f else 1f) + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .clickableAlpha(onClick = onClick) + .padding(16.dp) + ) { + SubscriptionAvatar(subscription = subscription, contact = 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, + ) + CaptionB( + text = subtitle, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MoneyCell(sats = subscription.displaySats) + } +} + +@Composable +internal fun SubscriptionDefaultIcon(size: Dp) { + Image( + painter = painterResource(R.drawable.subscription_default_icon), + contentDescription = null, + modifier = Modifier.size(size).clip(RoundedCornerShape(size / 5)) + .background(Colors.White).padding(size / 8) + ) +} + +@Composable +internal fun SubscriptionAvatar( + subscription: PaykitSubscription, + contact: PubkyProfile, + size: Dp, +) { + val iconUri = subscription.metadata.iconUri + if (iconUri == null && subscription.isCreatedByUser) { + SubscriptionDefaultIcon(size = size) + } else if (iconUri == null) { + PubkyContactAvatar(profile = contact, size = size) + } else { + PubkyImage(uri = iconUri, size = size, shape = RoundedCornerShape(size / 5)) + } +} 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 index 3b0acbc0da..5f5356ea7d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -13,7 +13,6 @@ 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 @@ -26,6 +25,7 @@ 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.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -49,6 +49,7 @@ 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 @@ -59,6 +60,7 @@ import to.bitkit.ext.dateTimeFormatterOf import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitRecurrenceUnit import to.bitkit.repositories.PaykitSubscription @@ -71,11 +73,9 @@ 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.MoneyMSB 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 @@ -107,6 +107,7 @@ fun SubscriptionsScreen( appViewModel: AppViewModel, onBack: () -> Unit, onRequestPayment: () -> Unit, + onCreateSubscription: () -> Unit, onDetails: (PaykitSubscriptionId) -> Unit, onPaymentRequestDetails: (PaykitPaymentRequestId) -> Unit, showPayments: Boolean = false, @@ -125,12 +126,13 @@ fun SubscriptionsScreen( initialTab = if (showPayments) SubscriptionTab.Payments else SubscriptionTab.Overview, pendingPaymentRequestCount = pendingPaymentRequests.size, onSubscription = { subscription -> - if (subscription.isProposalVisible(now)) { + if (subscription.isPayer && subscription.isProposalVisible(now)) { appViewModel.showSheet(Sheet.Subscription(SubscriptionRoute.Review(subscription.id))) } else { onDetails(subscription.id) } }, + onCreateSubscription = onCreateSubscription, paymentsContent = { PaymentRequestsScreen( appViewModel = appViewModel, @@ -153,19 +155,22 @@ internal fun SubscriptionsContent( initialTab: SubscriptionTab, pendingPaymentRequestCount: Int, onSubscription: (PaykitSubscription) -> Unit, + onCreateSubscription: () -> 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() + val proposals = subscriptions.filter { it.isPayer && it.isProposalVisible(now) } + val active = subscriptions.filter { it.isPayer && it.isActive(now) } + val expired = subscriptions.filter { it.isPayer && it.isExpired(now) && acceptedAt(it.id) != null } + val created = subscriptions.filter { it.isCreatedVisible(now) } + val hasVisibleSubscriptions = + proposals.isNotEmpty() || active.isNotEmpty() || expired.isNotEmpty() || created.isNotEmpty() var selectedTabIndex by rememberSaveable { mutableIntStateOf(initialTab.ordinal) } val selectedTab = SubscriptionTab.entries[selectedTabIndex] Column( modifier = Modifier .fillMaxSize() - .gradientBackground() + .background(Colors.Black) .navigationBarsPadding() .testTag("SubscriptionsScreen") ) { @@ -196,6 +201,7 @@ internal fun SubscriptionsContent( SubscriptionMetrics( monthlyCostSats = subscriptionMonthlyCostSats(subscriptions, now), activeCount = active.size, + createdCount = created.size, ) } subscriptionSection( @@ -219,8 +225,24 @@ internal fun SubscriptionsContent( now = now, onSubscription = onSubscription, ) + subscriptionSection( + titleRes = R.string.subscriptions__created, + subscriptions = created, + contacts = contacts, + now = now, + onSubscription = onSubscription, + ) } } + + if (selectedTab == SubscriptionTab.Overview) { + SecondaryButton( + text = stringResource(R.string.subscriptions__create), + onClick = onCreateSubscription, + modifier = Modifier.padding(horizontal = 16.dp).testTag("SubscriptionCreate") + ) + VerticalSpacer(16.dp) + } } } @@ -239,7 +261,7 @@ private fun LazyListScope.subscriptionSection( SubscriptionRow( subscription = subscription, contact = contacts.contactFor(subscription), - now = now, + subtitle = subscription.rowSubtitle(now), faded = subscription.isExpired(now), onClick = { onSubscription(subscription) }, ) @@ -282,41 +304,47 @@ private fun SubscriptionEmptyState(modifier: Modifier = Modifier) { Column( modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 24.dp) + .padding(horizontal = 16.dp, vertical = 32.dp) ) { FillHeight() Image( - painter = painterResource(R.drawable.subscription_clock), + painter = painterResource(R.drawable.subscription_intro_clock), contentDescription = null, modifier = Modifier .size(256.dp) - .align(Alignment.CenterHorizontally), + .align(Alignment.CenterHorizontally) ) - FillHeight() + VerticalSpacer(32.dp) Display( text = stringResource(R.string.subscriptions__empty_headline).withAccent(accentColor = Colors.Purple), ) - VerticalSpacer(12.dp) + VerticalSpacer(8.dp) BodyM(text = stringResource(R.string.subscriptions__empty_description), color = Colors.White64) } } @Composable -private fun SubscriptionMetrics(monthlyCostSats: Long, activeCount: Int) { +private fun SubscriptionMetrics(monthlyCostSats: Long, activeCount: Int, createdCount: Int) { Row( verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth() ) { Column(modifier = Modifier.weight(1f)) { Caption13Up(text = stringResource(R.string.subscriptions__monthly_cost), 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) - MoneyMSB(sats = monthlyCostSats) + Icon( + painterResource(R.drawable.ic_calendar), + contentDescription = null, + tint = Colors.Purple, + modifier = Modifier.size(24.dp) + ) + MoneyMSB(sats = monthlyCostSats, showSymbol = true) } } - Spacer(Modifier.size(width = 1.dp, height = 50.dp).background(Colors.White16)) - Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) { + VerticalDivider(color = Colors.White16, modifier = Modifier.height(50.dp)) + Column { Caption13Up(text = stringResource(R.string.subscriptions__active), color = Colors.White64) VerticalSpacer(8.dp) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { @@ -324,42 +352,15 @@ private fun SubscriptionMetrics(monthlyCostSats: Long, activeCount: Int) { 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, - ) + VerticalDivider(color = Colors.White16, modifier = Modifier.height(50.dp)) + Column { + Caption13Up(text = stringResource(R.string.subscriptions__created), color = Colors.White64) + VerticalSpacer(8.dp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painterResource(R.drawable.ic_asterisk), contentDescription = null, tint = Colors.Purple) + BodyMSB(text = createdCount.toString()) + } } - MoneyCell(sats = subscription.displaySats) } } @@ -410,12 +411,20 @@ fun SubscriptionDetailScreen( Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { MoneyDisplay(sats = subscription.displaySats, showSymbol = true) FillWidth() - PubkyContactAvatar(profile = contacts.contactFor(subscription), size = 48.dp) + SubscriptionAvatar( + subscription = subscription, + contact = contacts.contactFor(subscription), + size = 48.dp, + ) } } } item { SubscriptionDetailsGrid(subscription, now) } - val payments = paymentHistory.filter { it.belongsTo(subscription) } + val payments = if (subscription.isCreatedByUser) { + subscription.receivedPaymentRequests() + } else { + paymentHistory.filter { it.belongsTo(subscription) } + } if (payments.isNotEmpty()) { item { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -426,7 +435,8 @@ fun SubscriptionDetailScreen( contact = contacts.contactFor(subscription), compactSubtitle = subscription.note?.takeIf(String::isNotBlank) ?: stringResource(R.string.subscriptions__subscription), - isOutgoingPayment = true, + isOutgoingPayment = !subscription.isCreatedByUser, + showSignedAmount = subscription.isCreatedByUser, ) } } @@ -457,11 +467,7 @@ private fun SubscriptionDetailsGrid(subscription: PaykitSubscription, now: Insta 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) - }, + subscription.statusText(now), R.drawable.ic_check, Modifier.weight(1f), ) @@ -476,6 +482,22 @@ private fun SubscriptionDetailsGrid(subscription: PaykitSubscription, now: Insta FillWidth() } } + if (subscription.isCreatedByUser) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + SubscriptionDetailCell( + stringResource(R.string.subscriptions__subscribers), + if (subscription.lifecycleState == PaymentRequestLifecycleState.ACTIVE_RECURRING) "1" else "0", + R.drawable.ic_users, + Modifier.weight(1f), + ) + SubscriptionDetailCell( + stringResource(R.string.subscriptions__payments), + subscription.paidPeriods.size.toString(), + R.drawable.ic_received, + Modifier.weight(1f), + ) + } + } } } @@ -513,13 +535,13 @@ private fun SubscriptionDetailFooter( now: Instant, ) { val hasMoreInfo = subscription.metadata.description != null || subscription.metadata.benefits.isNotEmpty() - val canCancel = subscription.canCancel(now) + val canCancel = subscription.canCancel(now) && subscription.recurrence.endsAt == null if (!hasMoreInfo && !canCancel) return Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) ) { - if (hasMoreInfo) { + if (hasMoreInfo && !subscription.isCreatedByUser) { SecondaryButton( text = stringResource(R.string.subscriptions__more_info), onClick = { appViewModel.showSheet(Sheet.Subscription(SubscriptionRoute.Details(subscription.id))) }, @@ -528,7 +550,9 @@ private fun SubscriptionDetailFooter( } if (canCancel) { PrimaryButton( - text = stringResource(R.string.subscriptions__cancel), + text = stringResource( + if (subscription.isCreatedByUser) R.string.common__delete else R.string.subscriptions__cancel + ), onClick = { appViewModel.showSheet(Sheet.Subscription(SubscriptionRoute.Cancel(subscription.id))) }, modifier = Modifier.weight(1f), icon = { @@ -749,7 +773,7 @@ private fun SubscriptionProviderCard( verticalAlignment = Alignment.CenterVertically, modifier = cardModifier ) { - PubkyContactAvatar(profile = contact, size = 40.dp) + SubscriptionAvatar(subscription = subscription, contact = contact, size = 40.dp) Column(Modifier.padding(start = 16.dp).weight(1f)) { BodyMSB( text = subscription.note ?: stringResource(R.string.subscriptions__subscription), @@ -856,7 +880,15 @@ private fun SubscriptionCancel( .navigationBarsPadding() .padding(horizontal = 16.dp) ) { - SheetTopBar(titleText = stringResource(R.string.subscriptions__cancel_subscription)) + SheetTopBar( + titleText = stringResource( + if (subscription.isCreatedByUser) { + R.string.subscriptions__delete_subscription + } else { + R.string.subscriptions__cancel_subscription + } + ) + ) rememberMoneyText(sats = subscription.displaySats, reversed = true, showSymbol = true)?.let { Caption13Up(text = it.removeAccentTags(), color = Colors.White64) } @@ -878,7 +910,13 @@ private fun SubscriptionCancel( ) FillHeight() SwipeToConfirm( - text = stringResource(R.string.subscriptions__swipe_to_cancel), + text = stringResource( + if (subscription.isCreatedByUser) { + R.string.subscriptions__swipe_to_delete + } else { + R.string.subscriptions__swipe_to_cancel + } + ), color = Colors.Red, loading = loading, onConfirm = { @@ -936,7 +974,7 @@ private fun PaykitSubscription.frequencyValue(): String { } @Composable -private fun PaykitSubscription.subscriptionFrequencyText(): String { +internal fun PaykitSubscription.subscriptionFrequencyText(): String { if (recurrence.every != 1) return cadenceText() return when (recurrence.unit) { PaykitRecurrenceUnit.Day -> stringResource(R.string.subscriptions__daily_subscription) @@ -949,33 +987,63 @@ private fun PaykitSubscription.subscriptionFrequencyText(): String { } @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(), - ) +private fun PaykitSubscription.rowSubtitle(now: Instant): String { + createdRowSubtitle(now)?.let { return it } + return 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(), + ) + } } } } +@Composable +private fun PaykitSubscription.createdRowSubtitle(now: Instant): String? { + if (!isCreatedByUser) return null + return when { + isProposalVisible(now) -> stringResource( + if (deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + R.string.subscriptions__proposal_sent_status + } else { + R.string.subscriptions__proposal_queued_status + } + ) + isActive(now) -> stringResource( + if (paidPeriods.size == 1) { + R.string.subscriptions__created_summary_single_payment + } else { + R.string.subscriptions__created_summary + }, + paidPeriods.size, + ) + else -> null + } +} + 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.statusText(now: Instant): String = when { + isProposalVisible(now) -> stringResource(R.string.subscriptions__pending) + isActive(now) -> stringResource(R.string.subscriptions__active) + else -> stringResource(R.string.subscriptions__expired) +} @Composable private fun PaykitSubscription.timingTitle(now: Instant): String = when { @@ -1019,14 +1087,14 @@ private fun Instant.formatShortDate(): String = dateTimeFormatterOf("MMMM d") private fun Instant.formatFullDate(): String = dateTimeFormatterOf("MMMM d, yyyy") .format(java.time.Instant.ofEpochMilli(toEpochMilliseconds())) -private val PaykitSubscription.displaySats: Long +internal val PaykitSubscription.displaySats: Long get() = amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() internal fun subscriptionMonthlyCostSats( subscriptions: List, now: Instant, ): Long { - val total = subscriptions.filter { it.isActive(now) }.fold(BigDecimal.ZERO) { total, subscription -> + val total = subscriptions.filter { it.isPayer && it.isActive(now) }.fold(BigDecimal.ZERO) { total, subscription -> val annualPeriods = when (subscription.recurrence.unit) { PaykitRecurrenceUnit.Minute -> 525_600L PaykitRecurrenceUnit.Hour -> 8_760L diff --git a/app/src/main/java/to/bitkit/utils/SubscriptionIcon.kt b/app/src/main/java/to/bitkit/utils/SubscriptionIcon.kt new file mode 100644 index 0000000000..f6c5711537 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/SubscriptionIcon.kt @@ -0,0 +1,40 @@ +package to.bitkit.utils + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +internal object SubscriptionIcon { + /** Maximum pixel dimension of a subscription's public icon. */ + private const val MAX_DIMENSION = 400 + + /** JPEG quality used for public subscription icons. */ + private const val JPEG_QUALITY = 80 + + fun load(resolver: ContentResolver, uri: Uri): ByteArray = + encode(ImageDecoder.createSource(resolver, uri)) + + fun compress(bytes: ByteArray): ByteArray = encode(ImageDecoder.createSource(ByteBuffer.wrap(bytes))) + + private fun encode(source: ImageDecoder.Source): ByteArray { + val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + val scale = minOf(MAX_DIMENSION.toFloat() / info.size.width, MAX_DIMENSION.toFloat() / info.size.height, 1f) + decoder.setTargetSize( + (info.size.width * scale).toInt().coerceAtLeast(1), + (info.size.height * scale).toInt().coerceAtLeast(1), + ) + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + return try { + ByteArrayOutputStream().use { output -> + check(bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, output)) + output.toByteArray() + } + } finally { + bitmap.recycle() + } + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b80600e2b9..864fd2101f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -158,6 +158,7 @@ import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionDraft import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification @@ -4756,6 +4757,11 @@ class AppViewModel @Inject constructor( viewModelScope.launch { refreshPaymentRequestTargets(force = true) } } + fun showSubscriptionCreator() { + showSheet(Sheet.CreateSubscription) + viewModelScope.launch { refreshPaymentRequestTargets(force = true) } + } + fun subscription(id: PaykitSubscriptionId): PaykitSubscription? = paykitPaymentRequestRepo.subscriptions.value.firstOrNull { it.id == id } @@ -4852,6 +4858,47 @@ class AppViewModel @Inject constructor( .onFailure(::toast) }.await() + fun createSubscription( + draft: PaykitSubscriptionDraft, + target: PaykitPaymentRequestTarget, + onCreated: (PaykitSubscription) -> Unit, + ) { + val sourceSheet = currentSheet.value.takeIf { it is Sheet.CreateSubscription } + viewModelScope.launch { + paykitPaymentRequestRepo.proposeSubscription( + draft = draft, + target = target, + savedPublicKeys = pubkyRepo.contacts.value.map { it.publicKey }, + ).onSuccess { creation -> + val creatorIsCurrent = PubkyPublicKeyFormat.matches( + creation.creatorIdentity, + pubkyRepo.publicKey.value, + ) + if (creation.wasPublishedToActiveState && creatorIsCurrent) { + onCreated(creation.subscription) + } else { + if (sourceSheet != null && currentSheet.value === sourceSheet) hideSheet() + toast( + type = Toast.ToastType.INFO, + title = context.getString(R.string.subscriptions__title), + description = context.getString(R.string.subscriptions__proposal_queued_description), + testTag = "SubscriptionProposalQueuedToast", + ) + } + }.onFailure { + if (it is PaykitPaymentRequestError.SubscriptionTooLong) { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = context.getString(R.string.subscriptions__content_too_long), + ) + } else { + toast(it) + } + } + } + } + fun openIncomingPaymentRequest(id: PaykitPaymentRequestId) { openIncomingPaymentRequestWithTags(id, emptyList()) } diff --git a/app/src/main/res/drawable-nodpi/subscription_default_icon.png b/app/src/main/res/drawable-nodpi/subscription_default_icon.png new file mode 100644 index 0000000000..8301897232 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/subscription_default_icon.png differ diff --git a/app/src/main/res/drawable-nodpi/subscription_intro_clock.png b/app/src/main/res/drawable-nodpi/subscription_intro_clock.png new file mode 100644 index 0000000000..bedb798955 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/subscription_intro_clock.png differ diff --git a/app/src/main/res/drawable/ic_asterisk.xml b/app/src/main/res/drawable/ic_asterisk.xml new file mode 100644 index 0000000000..b2dcd8a28b --- /dev/null +++ b/app/src/main/res/drawable/ic_asterisk.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_timer_outline.xml b/app/src/main/res/drawable/ic_timer_outline.xml new file mode 100644 index 0000000000..17b96cc4bc --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_outline.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b8d50eb58..edea9b4ea1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1039,8 +1039,20 @@ Active Cancel Cancel Subscription + Choose Recipient + Shorten the subscription name or description and try again. + Create + Create Subscription + Created + 1 subscriber · %1$d payments + 1 subscriber · 1 payment + Icon + Tap to upload a custom icon Daily - Daily Subscription + Daily subscription + Delete Subscription + Description + What is this subscription for? Subscription Details At the moment, you don’t have any active subscriptions from any providers. Subscriptions]]> @@ -1055,10 +1067,13 @@ You’re subscribed, but your first payment wasn’t sent. First billing period ends %1$s. Each period is charged in full. Frequency + Could not load this image. Choose another image and try again. Monthly Monthly Cost - Monthly Subscription + Monthly subscription More Info + Subscription Name + Subscription name Ongoing Overview Open Bitkit to review a subscription payment. @@ -1068,15 +1083,26 @@ Per Month Per Week Per Year + Pending + Your subscription proposal is queued and will send automatically. + Proposal]]> + Proposal queued + Queued + You have sent a subscription proposal to + Proposal]]> + Proposal sent Proposals + Propose Subscription Renews Renews %1$s Retry Payment Review & Subscribe Status Subscribed + Subscribers Subscription Swipe To Cancel + Swipe To Delete Swipe To Subscribe Swipe To Subscribe & Pay Subscriptions @@ -1085,9 +1111,9 @@ Unsupported Frequency This subscription uses payment details that Bitkit does not support yet. Weekly - Weekly Subscription + Weekly subscription Yearly - Yearly Subscription + Yearly subscription %1$s sats Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot). LOOKING FOR FUNDS... diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index c8b87c99a8..299799e598 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -28,6 +28,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq @@ -37,6 +38,7 @@ import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest @@ -143,6 +145,237 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(Instant.parse("2027-02-01T08:00:00Z"), request.billingPeriod?.endsAt) } + @Test + fun `refresh keeps creator subscription without generating a payer payment`() = test { + val metadataText = """ + { + "note":"Creator plan", + "subscription":{ + "version":1, + "description":"Monthly support", + "benefits":[], + "icon_uri":"pubky://creator/icon" + } + } + """.trimIndent() + val metadata = mock { + on { exportText() } doReturn metadataText + } + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + metadata = metadata, + ), + ), + ) + + sut.refresh().getOrThrow() + + val subscription = sut.subscriptions.value.single() + assertTrue(subscription.isCreatedByUser) + assertEquals("Creator plan", subscription.note) + assertEquals("Monthly support", subscription.metadata.description) + assertEquals("pubky://creator/icon", subscription.metadata.iconUri) + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) + } + + @Test + fun `creator payments aggregate duplicate proof events for one billing period`() = test { + val period = BillingPeriod("2027-01-01T08:00:00Z", "2027-02-01T08:00:00Z") + val first = mock { + on { billingPeriod } doReturn period + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + val second = mock { + on { billingPeriod } doReturn period + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + val offSchedule = mock { + on { billingPeriod } doReturn BillingPeriod(period.startsAt, "2027-02-02T08:00:00Z") + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + val subscription = requireNotNull( + paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paymentProofs = listOf(first, second, offSchedule), + ).toPaykitSubscription() + ) + + assertEquals(1, subscription.paidPeriods.size) + val received = subscription.receivedPaymentRequests().single() + assertEquals(PaykitPaymentRequestDirection.Outgoing, received.direction) + assertEquals(PaymentRequestLifecycleState.PROOF_SUBMITTED, received.lifecycleState) + assertEquals(PaykitPaymentProofKind.Lightning, received.paymentProofKind) + } + + @Test + fun `creator proposal sends recurring terms and stays queued until delivery`() = test { + val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + listOf(PaykitReceiverPaths.SERVER), + ) + whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))) + .thenAnswer { invocation -> + val proposal = invocation.getArgument(2) + paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + expiresAt = proposal.proposalExpiresAt, + recurrence = requireNotNull(proposal.recurrence).let { + PaymentRequestRecurrence(it.every, it.unit, it.startsAt, it.anchor, it.endsAt) + }, + metadata = mock { + on { exportText() } doReturn proposal.metadataJson + }, + ) + } + val expiresAt = clock.now().plus(60.seconds) + + val creation = sut.proposeSubscription( + draft = PaykitSubscriptionDraft( + amountSats = 100_000uL, + name = " Monthly support ", + description = " Thank you ", + frequency = PaykitRecurrenceUnit.Month, + expiresAt = expiresAt, + ), + target = target, + savedPublicKeys = listOf(COUNTERPARTY), + ).getOrThrow() + + val captured = argumentCaptor() + verifyBlocking(paykitSdkService) { + proposePaymentRequest( + eq(COUNTERPARTY), + eq(PaykitReceiverPaths.SERVER), + captured.capture(), + eq(LOCAL_IDENTITY), + ) + } + with(captured.firstValue) { + assertEquals("0.001", amountValue) + assertEquals(expiresAt.toString(), proposalExpiresAt) + assertEquals(1u, recurrence?.every) + assertEquals("month", recurrence?.unit) + assertEquals(clock.now().toString(), recurrence?.startsAt) + assertEquals(recurrence?.startsAt, recurrence?.anchor) + assertEquals(null, recurrence?.endsAt) + assertTrue(metadataJson.contains("\"note\":\"Monthly support\"")) + assertTrue(metadataJson.contains("\"description\":\"Thank you\"")) + } + assertTrue(creation.subscription.isCreatedByUser) + assertEquals(PaykitPaymentRequestDeliveryStatus.Queued, creation.subscription.deliveryStatus) + assertEquals(listOf(creation.subscription), sut.subscriptions.value) + } + + @Test + fun `oversized creator proposal is rejected before icon upload or enqueue`() = test { + val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) + .thenReturn(listOf(PaykitReceiverPaths.SERVER)) + + listOf(null, byteArrayOf(0, 1, 2)).forEach { icon -> + val result = sut.proposeSubscription( + draft = PaykitSubscriptionDraft( + amountSats = 1000uL, + name = "Support", + description = "💜".repeat(256), + frequency = PaykitRecurrenceUnit.Month, + expiresAt = clock.now() + 60.seconds, + iconBytes = icon, + ), + target = target, + savedPublicKeys = listOf(COUNTERPARTY), + ) + assertEquals(PaykitPaymentRequestError.SubscriptionTooLong, result.exceptionOrNull()) + } + + verifyBlocking(paykitSdkService, never()) { uploadProfileAvatar(any(), any(), anyOrNull()) } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + assertTrue(sut.subscriptions.value.isEmpty()) + assertFalse(sut.isCreatingRequest.value) + } + + @Test + fun `creator can delete a pending proposal without payer proof checks`() = test { + val proposed = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE) + val canceled = paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + state = PaymentRequestLifecycleState.CANCELED, + ) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(proposed), listOf(canceled)) + whenever( + paykitSdkService.cancelPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ) + ).thenReturn(canceled) + sut.refresh().getOrThrow() + + sut.cancel(sut.subscriptions.value.single()).getOrThrow() + + verifyBlocking(paymentProofRepo, never()) { + protectedRequestIdsForSubscriptionCancellation(any(), any()) + } + verifyBlocking(paykitSdkService) { + cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + } + assertEquals(PaymentRequestLifecycleState.CANCELED, sut.subscriptions.value.single().lifecycleState) + assertFalse(sut.subscriptions.value.single().isCreatedVisible(clock.now())) + } + + @Test + fun `deleting a paid creator subscription keeps received history accessible`() = test { + val period = BillingPeriod("2027-01-01T08:00:00Z", "2027-02-01T08:00:00Z") + val proof = mock { + on { billingPeriod } doReturn period + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + val active = paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + state = PaymentRequestLifecycleState.ACTIVE_RECURRING, + paymentProofs = listOf(proof), + ) + val canceled = paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + state = PaymentRequestLifecycleState.CANCELED, + paymentProofs = listOf(proof), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(active), listOf(canceled)) + whenever( + paykitSdkService.cancelPaymentRequest(COUNTERPARTY, PaykitReceiverPaths.SERVER, PAYMENT_REQUEST_ID) + ).thenReturn(canceled) + sut.refresh().getOrThrow() + val subscription = sut.subscriptions.value.single() + val received = subscription.receivedPaymentRequests() + assertEquals(1, received.size) + + sut.cancel(subscription).getOrThrow() + sut.refresh().getOrThrow() + + val retained = sut.subscriptions.value.single() + assertTrue(retained.isCreatedVisible(clock.now())) + assertTrue(retained.isExpired(clock.now())) + assertFalse(retained.isActive(clock.now())) + assertFalse(retained.canCancel(clock.now())) + assertFalse(retained.copy(role = PaykitSubscriptionRole.Payer).isCreatedVisible(clock.now())) + assertEquals(received, retained.receivedPaymentRequests()) + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) + } + @Test fun `accepting subscription returns current period and preserves payment targets`() = test { val proposal = paymentRequestRecord() @@ -508,6 +741,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Suppress("LongParameterList") private fun paymentRequestRecord( id: String = PAYMENT_REQUEST_ID, + role: PaymentRequestLocalRole = PaymentRequestLocalRole.PAYER, state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, amount: String = "0.001", expiresAt: String? = null, @@ -519,7 +753,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat counterparty = COUNTERPARTY, counterpartyReceiverPath = PaykitReceiverPaths.SERVER, paymentRequestId = id, - localRole = PaymentRequestLocalRole.PAYER, + localRole = role, state = state, proposalStreamItemId = 1uL, proposalOutboundMessageId = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt new file mode 100644 index 0000000000..82ce0ec167 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitSubscriptionProposalTest.kt @@ -0,0 +1,60 @@ +package to.bitkit.repositories + +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Test +import to.bitkit.services.PaykitPaymentRequestProposalTerms +import to.bitkit.services.PaykitPaymentRequestRecurrenceTerms +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PaykitSubscriptionProposalTest { + @Test + fun `transport limit includes envelope endpoints and public icon`() { + val empty = terms("", PaykitSubscriptionProposal.reservedIconUri) + val available = PaykitSubscriptionProposal.MAX_MESSAGE_BYTES - PaykitSubscriptionProposal.encodedSize(empty) + assertTrue(available > 0) + val full = terms("a".repeat(available), PaykitSubscriptionProposal.reservedIconUri) + assertEquals(1000, PaykitSubscriptionProposal.encodedSize(full)) + PaykitSubscriptionProposal.validate(full) + assertFailsWith { + val oversized = terms("a".repeat(available + 1), PaykitSubscriptionProposal.reservedIconUri) + PaykitSubscriptionProposal.validate(oversized) + } + } + + @Test + fun `size counts UTF8 and JSON escaping rather than characters`() { + val base = PaykitSubscriptionProposal.encodedSize(terms("")) + assertEquals(base + 4, PaykitSubscriptionProposal.encodedSize(terms("💜"))) + assertEquals(base + 6, PaykitSubscriptionProposal.encodedSize(terms("\"\n\\"))) + assertEquals(base + 19, PaykitSubscriptionProposal.encodedSize(terms("https://example.com"))) + } + + private fun terms(description: String, iconUri: String? = null) = PaykitPaymentRequestProposalTerms( + amountValue = "0.001", + paymentReference = "bitkit-00000000-0000-0000-0000-000000000000", + proposalExpiresAt = "2027-01-22T08:00:00.000Z", + recurrence = PaykitPaymentRequestRecurrenceTerms( + every = 1u, + unit = "month", + startsAt = "2027-01-15T08:00:00.000Z", + anchor = "2027-01-15T08:00:00.000Z", + ), + acceptedPaymentEndpointIdentifiers = listOf("bitcoin:regtest", "lightning:bolt11", "lightning:lnurl"), + metadataJson = buildJsonObject { + put("note", "Support") + put( + "subscription", + buildJsonObject { + put("version", 1) + put("description", description) + put("benefits", buildJsonArray { }) + iconUri?.let { put("icon_uri", it) } + }, + ) + }.toString(), + ) +} diff --git a/changelog.d/next/1239.added.md b/changelog.d/next/1239.added.md new file mode 100644 index 0000000000..6da1146f75 --- /dev/null +++ b/changelog.d/next/1239.added.md @@ -0,0 +1 @@ +Bitkit can now propose recurring subscriptions to Paykit contacts. diff --git a/docs/screens-map.md b/docs/screens-map.md index 4a1ecb5d9c..2aac992646 100644 --- a/docs/screens-map.md +++ b/docs/screens-map.md @@ -121,7 +121,8 @@ Frame names are stable across handoff iterations; node ids are not, so the map l | Android | Figma | | - | - | -| SubscriptionsScreen.kt | Subscriptions › Subscriptions overview | +| CreateSubscriptionScreen.kt | Subscriptions › Create Subscription / Choose Subscription Recipient / Sent Subscription Proposal | +| SubscriptionsScreen.kt | Subscriptions › Subscriptions Intro / Subscriptions overview | ## ui/screens/transfer