From 5442c9f6f638cc85cbd4f0a05b2a2212ffbc9d11 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 20 Aug 2026 04:20:20 -0500 Subject: [PATCH 1/8] feat: add private payment requests --- app/build.gradle.kts | 2 + .../to/bitkit/ui/components/SheetHostTest.kt | 87 ++++ .../java/to/bitkit/data/keychain/Keychain.kt | 1 + app/src/main/java/to/bitkit/env/Env.kt | 1 + .../PaykitPaymentRequestPresentationStore.kt | 41 ++ .../repositories/PaykitPaymentRequestRepo.kt | 400 ++++++++++++++++-- .../bitkit/repositories/PrivatePaykitRepo.kt | 18 +- .../java/to/bitkit/repositories/PubkyRepo.kt | 5 +- .../to/bitkit/services/PaykitSdkService.kt | 156 ++++++- app/src/main/java/to/bitkit/ui/ContentView.kt | 25 ++ .../java/to/bitkit/ui/components/Money.kt | 32 +- .../java/to/bitkit/ui/components/SheetHost.kt | 45 +- .../CreatePaymentRequestScreen.kt | 346 +++++++++++++++ .../paymentrequests/PaymentRequestsScreen.kt | 317 ++++++++++++++ .../bitkit/ui/screens/wallets/HomeScreen.kt | 86 ++++ .../wallets/receive/ReceiveQrScreen.kt | 13 + .../screens/wallets/receive/ReceiveSheet.kt | 72 ++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 337 +++++++++++---- app/src/main/res/values/strings.xml | 28 ++ .../PaykitPaymentRequestRepoTest.kt | 236 ++++++++++- .../bitkit/services/PaykitSdkServiceTest.kt | 21 + .../PaymentRequestExpirationTest.kt | 26 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 238 ++++++++++- changelog.d/next/payment-requests.added.md | 1 + gradle/libs.versions.toml | 2 +- 25 files changed, 2384 insertions(+), 152 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt create mode 100644 app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestExpirationTest.kt create mode 100644 changelog.d/next/payment-requests.added.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1866008f4a..e81730df21 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -79,6 +79,7 @@ val e2eBackendEnv = providers.environmentVariable("E2E_BACKEND").orElse("local") val e2eLocalHostEnv = providers.environmentVariable("E2E_LOCAL_HOST").orElse("10.0.2.2") val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL") .orElse(e2eLocalHostEnv.map { "http://$it:6288" }) +val e2eHomeserverPubkyEnv = providers.environmentVariable("E2E_HOMESERVER_PUBKY").orElse("") val geoEnv = envFlag("GEO", default = true) val paykitUiDisabledEnv = envFlag("PAYKIT_UI_DISABLED", default = false) val trezorBridgeEnv = localProp("TREZOR_BRIDGE").map { it.toBoolean().toString() }.orElse("false") @@ -323,6 +324,7 @@ androidComponents { buildConfigFields.put("E2E_BACKEND", e2eBackendEnv.stringField()) buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField()) buildConfigFields.put("E2E_HOMEGATE_URL", e2eHomegateUrlEnv.stringField()) + buildConfigFields.put("E2E_HOMESERVER_PUBKY", e2eHomeserverPubkyEnv.stringField()) buildConfigFields.put("TREZOR_BRIDGE", trezorBridgeEnv.booleanField()) buildConfigFields.put("TREZOR_BRIDGE_URL", trezorBridgeUrlEnv.stringField()) buildConfigFields.put("GEO", geoEnv.booleanField()) diff --git a/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt new file mode 100644 index 0000000000..c5a73d6b50 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt @@ -0,0 +1,87 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import androidx.compose.ui.unit.dp +import androidx.test.espresso.Espresso.pressBack +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface + +@HiltAndroidTest +@ComposeUi +class SheetHostTest { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun disabledDismissalBlocksBackDragAndScrim() { + var dismissCount = 0 + var backgroundClickCount = 0 + composeTestRule.setContent { + AppThemeSurface { + SheetHost( + shouldExpand = true, + onDismiss = { dismissCount++ }, + dismissEnabled = false, + sheets = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(320.dp) + .testTag("LockedSheet") + ) + }, + content = { + Box( + Modifier + .fillMaxSize() + .clickable { backgroundClickCount++ } + ) + }, + ) + } + } + composeTestRule.onNodeWithTag("LockedSheet").assertIsDisplayed() + + pressBack() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("LockedSheet").assertIsDisplayed() + + composeTestRule.onNodeWithTag("LockedSheet").performTouchInput { swipeDown() } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("LockedSheet").assertIsDisplayed() + + composeTestRule.onRoot().performTouchInput { click(Offset(center.x, 50f)) } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("LockedSheet").assertIsDisplayed() + assertEquals(0, dismissCount) + assertEquals(0, backgroundClickCount) + } +} diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 00d4f31f52..ab88c2cd7d 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -234,6 +234,7 @@ class Keychain @Inject constructor( PAYKIT_SESSION, PAYKIT_RECEIVER_NOISE_SECRET_KEY, PAYKIT_SDK_STATE, + PAYKIT_PRESENTED_PAYMENT_REQUESTS, PUBKY_SECRET_KEY, } } diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index e857ef58fe..3b306a9cc9 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -24,6 +24,7 @@ internal object Env { val isLocalE2eBackend = isE2eTest && e2eBackend == "local" const val e2eLocalHost = BuildConfig.E2E_LOCAL_HOST const val e2eHomegateUrl = BuildConfig.E2E_HOMEGATE_URL + val e2eHomeserverPubky = BuildConfig.E2E_HOMESERVER_PUBKY.takeIf { isLocalE2eBackend && it.isNotBlank() } val network = Network.valueOf(BuildConfig.NETWORK) val locales = BuildConfig.LOCALES.split(",") const val walletSyncIntervalSecs = 10_uL diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt new file mode 100644 index 0000000000..bb28ee0507 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestPresentationStore.kt @@ -0,0 +1,41 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import to.bitkit.data.keychain.Keychain +import to.bitkit.models.PubkyPublicKeyFormat +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PaykitPaymentRequestPresentationStore @Inject constructor( + private val keychain: Keychain, +) { + private val mutex = Mutex() + + @Serializable + private data class State( + val idsByIdentity: Map> = emptyMap(), + ) + + fun load(identity: String): Set { + val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return emptySet() + val value = keychain.loadString(Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name) ?: return emptySet() + return Json.decodeFromString(value).idsByIdentity[normalizedIdentity].orEmpty().toSet() + } + + suspend fun save(identity: String, ids: Set) { + mutex.withLock { + val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return@withLock + val current = keychain.loadString(Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name) + ?.let { Json.decodeFromString(it) } + ?: State() + val state = current.copy(idsByIdentity = current.idsByIdentity + (normalizedIdentity to ids.toList())) + keychain.upsertString(Keychain.Key.PAYKIT_PRESENTED_PAYMENT_REQUESTS.name, Json.encodeToString(state)) + } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 999a72349b..18352320d4 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -2,10 +2,13 @@ package to.bitkit.repositories +import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport +import com.synonym.paykit.OutboundPrivateMessageStatus import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job @@ -13,20 +16,34 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import to.bitkit.async.appScope +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.ext.runSuspendCatching +import to.bitkit.flags.PaykitFeatureFlags import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.satsToMsat +import to.bitkit.services.PaykitPaymentRequestProposalTerms +import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.math.BigDecimal +import java.util.UUID import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton @@ -35,6 +52,7 @@ import kotlin.time.Duration import kotlin.time.ExperimentalTime import kotlin.time.Instant +@Serializable data class PaykitPaymentRequestId( val paymentRequestId: String, val counterparty: String, @@ -47,8 +65,11 @@ data class PaykitPaymentRequest( val counterpartyReceiverPath: String, val amountValue: String, val amountSats: ULong, + val note: String? = null, + val createdAt: Instant? = null, val expiresAt: Instant?, val acceptedPaymentEndpointIdentifiers: List, + val deliveryStatus: PaykitPaymentRequestDeliveryStatus? = null, ) { val id: PaykitPaymentRequestId get() = PaykitPaymentRequestId(paymentRequestId, counterparty, counterpartyReceiverPath) @@ -64,15 +85,32 @@ data class PaykitPaymentRequest( fun acceptsPaymentAmount(amountSats: ULong): Boolean = amountSats == this.amountSats } +enum class PaykitPaymentRequestDeliveryStatus { Queued, Sent } + +data class PaykitPaymentRequestTarget( + val publicKey: String, + val receiverPath: String, +) + +data class PaykitPaymentRequestDraft( + val amountSats: ULong, + val note: String, + val expiresAt: Instant, +) + 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") } +@Suppress("TooManyFunctions") @Singleton class PaykitPaymentRequestRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val paykitSdkService: PaykitSdkService, + private val settingsStore: SettingsStore, + private val presentationStore: PaykitPaymentRequestPresentationStore, private val clock: Clock, ) { companion object { @@ -80,18 +118,68 @@ class PaykitPaymentRequestRepo @Inject constructor( } private val operationMutex = Mutex() + private val creationMutex = Mutex() + private val processingLock = Any() + private val processingRequestIds = mutableSetOf() private val stateGeneration = AtomicLong() private val repoScope = appScope(ioDispatcher, TAG) private var expirationJob: Job? = null private val _pendingRequests = MutableStateFlow>(emptyList()) val pendingRequests: StateFlow> = _pendingRequests.asStateFlow() + private val _sentRequests = MutableStateFlow>(emptyList()) + val sentRequests: StateFlow> = _sentRequests.asStateFlow() + private val _eligibleTargets = MutableStateFlow>(emptyList()) + val eligibleTargets: StateFlow> = _eligibleTargets.asStateFlow() + private val _isCreatingRequest = MutableStateFlow(false) + val isCreatingRequest: StateFlow = _isCreatingRequest.asStateFlow() + + @Volatile + private var activeIdentity: String? = null + private var presentedRequestIds = emptySet() + + suspend fun activate(identity: String) = withContext(ioDispatcher) { + val normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return@withContext + if (!PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) { + stateGeneration.incrementAndGet() + } + operationMutex.withLock { + if (PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity)) return@withLock + clearStateLocked() + activeIdentity = normalizedIdentity + presentedRequestIds = runSuspendCatching { presentationStore.load(normalizedIdentity) } + .onFailure { Logger.warn("Failed to restore surfaced Paykit payment requests", it, context = TAG) } + .getOrDefault(emptySet()) + } + } + + fun automaticPendingRequests(): List = + _pendingRequests.value.filterNot { it.id in presentedRequestIds } + + fun pendingRequest(id: PaykitPaymentRequestId): PaykitPaymentRequest? = + _pendingRequests.value.firstOrNull { it.id == id } + + suspend fun markPresented(request: PaykitPaymentRequest): Boolean = withContext(ioDispatcher) { + operationMutex.withLock { + if (_pendingRequests.value.none { it.id == request.id }) return@withLock false + val identity = activeIdentity ?: return@withLock false + presentedRequestIds = presentedRequestIds + request.id + runSuspendCatching { presentationStore.save(identity, presentedRequestIds) } + .onFailure { Logger.warn("Failed to persist surfaced Paykit payment requests", it, context = TAG) } + true + } + } - suspend fun refresh(): Result { + suspend fun refresh(savedPublicKeys: List = emptyList()): Result { val generation = stateGeneration.get() + val expectedIdentity = activeIdentity return withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { - runSuspendCatching { synchronizeLocked(generation) } + if (!isAvailable()) { + clearStateLocked() + return@withLock + } + runSuspendCatching { synchronizeLocked(generation, savedPublicKeys, expectedIdentity) } .onFailure { discardExpiredRequestsLocked() } .getOrThrow() } @@ -101,6 +189,69 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + suspend fun propose( + draft: PaykitPaymentRequestDraft, + target: PaykitPaymentRequestTarget, + savedPublicKeys: List, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (!creationMutex.tryLock()) throw PaykitPaymentRequestError.OperationInProgress + _isCreatingRequest.update { true } + try { + operationMutex.withLock { + val generation = stateGeneration.get() + val expectedIdentity = activeIdentity ?: throw PaykitPaymentRequestError.RequestUnavailable + val proposalDate = clock.now() + if (draft.amountSats == 0uL || !isAvailable()) throw PaykitPaymentRequestError.RequestUnavailable + if (draft.expiresAt <= proposalDate) throw PaykitPaymentRequestError.RequestExpired + + val targets = eligibleTargets(savedPublicKeys, expectedIdentity) + if (target !in targets) throw PaykitPaymentRequestError.RequestUnavailable + val settings = settingsStore.data.first() + if (!settings.sharesPrivatePaykitEndpoints) throw PaykitPaymentRequestError.RequestUnavailable + val endpointIdentifiers = acceptedPaymentEndpointIdentifiers(settings) + if (endpointIdentifiers.isEmpty()) throw PaykitPaymentRequestError.RequestUnavailable + + val note = draft.note.trim().take(256) + val proposal = PaykitPaymentRequestProposalTerms( + amountValue = draft.amountSats.toBitcoinAmount(), + paymentReference = "bitkit-${UUID.randomUUID()}", + proposalExpiresAt = draft.expiresAt.toString(), + acceptedPaymentEndpointIdentifiers = endpointIdentifiers, + metadataJson = JsonObject(mapOf("note" to JsonPrimitive(note))).toString(), + ) + val record = paykitSdkService.proposePaymentRequest( + counterparty = target.publicKey, + counterpartyReceiverPath = target.receiverPath, + proposal = proposal, + expectedIdentity = expectedIdentity, + ) + val reports = processPendingMessages() + + val request = record.toCreatedPaykitPaymentRequest( + draft = draft.copy(note = note), + target = target, + endpointIdentifiers = endpointIdentifiers, + createdAt = proposalDate, + reports = reports, + ) + if (isCurrentState(generation, expectedIdentity)) { + _sentRequests.update { requests -> + listOf(request) + requests.filterNot { it.id == request.id } + } + scheduleExpirationLocked() + } + request + } + } finally { + _isCreatingRequest.update { false } + creationMutex.unlock() + } + }.onFailure { + Logger.warn("Failed to create Paykit payment request", it, context = TAG) + } + } + suspend fun accept(request: PaykitPaymentRequest): Result = updateRequest(request) { paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, @@ -111,67 +262,171 @@ class PaykitPaymentRequestRepo @Inject constructor( Logger.warn("Failed to accept incoming Paykit payment request", it, context = TAG) } + suspend fun reject(request: PaykitPaymentRequest): Result = updateRequest(request) { + paykitSdkService.rejectPaymentRequest( + counterparty = it.counterparty, + counterpartyReceiverPath = it.counterpartyReceiverPath, + paymentRequestId = it.paymentRequestId, + ) + }.onFailure { + Logger.warn("Failed to reject incoming Paykit payment request", it, context = TAG) + } + fun isPending(request: PaykitPaymentRequest): Boolean = !request.isExpired(clock.now()) && _pendingRequests.value.any { it.id == request.id } + fun isProcessing(request: PaykitPaymentRequest): Boolean = synchronized(processingLock) { + request.id in processingRequestIds + } + suspend fun clear() { stateGeneration.incrementAndGet() withContext(ioDispatcher) { operationMutex.withLock { expirationJob?.cancel() expirationJob = null - _pendingRequests.update { emptyList() } + clearStateLocked() + activeIdentity = null + presentedRequestIds = emptySet() } } } - private suspend fun synchronizeLocked(generation: Long) { + private suspend fun synchronizeLocked( + generation: Long, + savedPublicKeys: List, + expectedIdentity: String?, + ) { processPendingMessages() paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() - val requests = paykitSdkService.actionableReceivedPaymentRequests().mapNotNull { - it.toPaykitPaymentRequest(now) + val incoming = paykitSdkService.actionableReceivedPaymentRequests().mapNotNull { + it.toPaykitPaymentRequest(PaymentRequestLocalRole.PAYER, now) } - if (stateGeneration.get() != generation) return - _pendingRequests.update { requests } + val sent = paykitSdkService.paymentRequests().mapNotNull { + it.toPaykitPaymentRequest(PaymentRequestLocalRole.PAYEE, now) + } + val targets = expectedIdentity?.let { eligibleTargets(savedPublicKeys, it) }.orEmpty() + if ( + stateGeneration.get() != generation || + !PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity) + ) { + return + } + _pendingRequests.update { incoming } + _sentRequests.update { sent } + _eligibleTargets.update { targets } + prunePresentedRequestIds(incoming) scheduleExpirationLocked() } + private fun isCurrentState(generation: Long, expectedIdentity: String?): Boolean = + stateGeneration.get() == generation && PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity) + + private suspend fun eligibleTargets( + savedPublicKeys: List, + expectedIdentity: String, + ): List { + val settings = settingsStore.data.first() + if (!settings.sharesPrivatePaykitEndpoints || acceptedPaymentEndpointIdentifiers(settings).isEmpty()) { + return emptyList() + } + val savedKeys = savedPublicKeys.mapNotNull(PubkyPublicKeyFormat::normalized).distinct() + val identityStatus = paykitSdkService.identityStatus() + if ( + savedKeys.isEmpty() || + identityStatus?.liveSessionAvailable != true || + !PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) + ) { + return emptyList() + } + val linkedPaths = mutableMapOf>() + paykitSdkService.linkedPeers() + .filter { it.state == LinkedPeerState.LINKED } + .forEach { peer -> + val publicKey = PubkyPublicKeyFormat.normalized(peer.counterparty) ?: return@forEach + if (peer.counterpartyReceiverPath in PaykitReceiverPaths.supported) { + linkedPaths.getOrPut(publicKey, ::mutableSetOf) += peer.counterpartyReceiverPath + } + } + + return savedKeys.mapNotNull { publicKey -> + val linked = linkedPaths[publicKey] ?: return@mapNotNull null + val capable = runSuspendCatching { paykitSdkService.paymentRequestReceiverPaths(publicKey) } + .onFailure { + Logger.warn( + "Failed to inspect payment request support for '${PubkyPublicKeyFormat.redacted(publicKey)}'", + it, + context = TAG, + ) + } + .getOrDefault(emptyList()) + val receiverPath = PaykitReceiverPaths.ordered.firstOrNull { it in linked && it in capable } + ?: return@mapNotNull null + PaykitPaymentRequestTarget(publicKey, receiverPath) + } + } + + private fun acceptedPaymentEndpointIdentifiers(settings: SettingsData): List = buildList { + if (PublicPaykitRepo.isLightningPaymentOptionEnabled(settings)) add(MethodId.Bolt11.rawValue) + if (PublicPaykitRepo.isOnchainPaymentOptionEnabled(settings)) { + addAll(MethodId.entries.filter { it.isOnchain }.map(MethodId::rawValue)) + } + } + + private suspend fun isAvailable(): Boolean = activeIdentity != null && + PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first()) + private suspend fun updateRequest( request: PaykitPaymentRequest, operation: suspend (PaykitPaymentRequest) -> Unit, ): Result = withContext(ioDispatcher) { runSuspendCatching { - operationMutex.withLock { - if (request.isExpired(clock.now())) { + val isNewAction = synchronized(processingLock) { processingRequestIds.add(request.id) } + if (!isNewAction) throw PaykitPaymentRequestError.OperationInProgress + try { + operationMutex.withLock { + if (request.isExpired(clock.now())) { + discardExpiredRequestsLocked() + throw PaykitPaymentRequestError.RequestExpired + } + val current = _pendingRequests.value.firstOrNull { it.id == request.id } + ?: throw PaykitPaymentRequestError.RequestUnavailable + + operation(current) + _pendingRequests.update { requests -> requests.filterNot { it.id == current.id } } discardExpiredRequestsLocked() - throw PaykitPaymentRequestError.RequestExpired + processPendingMessages() + Unit } - val current = _pendingRequests.value.firstOrNull { it.id == request.id } - ?: throw PaykitPaymentRequestError.RequestUnavailable - - operation(current) - _pendingRequests.update { requests -> requests.filterNot { it.id == current.id } } - discardExpiredRequestsLocked() - processPendingMessages() + } finally { + synchronized(processingLock) { processingRequestIds.remove(request.id) } } } } - private suspend fun processPendingMessages() { + private suspend fun processPendingMessages(): List = runSuspendCatching { paykitSdkService.processPendingPrivateMessages() } .onSuccess(::logOutboundFailures) .onFailure { Logger.warn("Failed to deliver pending Paykit private messages", it, context = TAG) } - } + .getOrDefault(emptyList()) private fun logOutboundFailures(reports: List) { - reports.forEach { - val error = it.error ?: return@forEach - Logger.warn( - "Failed to deliver Paykit private messages to '${PubkyPublicKeyFormat.redacted(it.counterparty)}': " + - "'${error.redactedContext()}'", - context = TAG, - ) + reports.forEach { report -> + report.error?.let { + Logger.warn( + "Failed to deliver Paykit private messages to " + + "'${PubkyPublicKeyFormat.redacted(report.counterparty)}': '${it.redactedContext()}'", + context = TAG, + ) + } + report.report?.failed.orEmpty().forEach { + Logger.warn( + "Failed to deliver Paykit private message '${it.outboundMessageId}' to " + + "'${PubkyPublicKeyFormat.redacted(report.counterparty)}': '${it.error.redactedContext()}'", + context = TAG, + ) + } } } @@ -186,17 +441,40 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - private fun discardExpiredRequestsLocked() { + private suspend fun discardExpiredRequestsLocked() { val now = clock.now() _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } + _sentRequests.update { requests -> requests.filterNot { it.isExpired(now) } } + prunePresentedRequestIds(_pendingRequests.value) scheduleExpirationLocked() } + private suspend fun prunePresentedRequestIds(requests: List) { + val requestIds = requests.mapTo(mutableSetOf()) { it.id } + val prunedIds = presentedRequestIds.intersect(requestIds) + if (prunedIds == presentedRequestIds) return + presentedRequestIds = prunedIds + val identity = activeIdentity ?: return + runSuspendCatching { presentationStore.save(identity, prunedIds) } + .onFailure { Logger.warn("Failed to persist surfaced Paykit payment requests", it, context = TAG) } + } + + private fun clearStateLocked() { + expirationJob?.cancel() + expirationJob = null + _pendingRequests.update { emptyList() } + _sentRequests.update { emptyList() } + _eligibleTargets.update { emptyList() } + } + private fun scheduleExpirationLocked() { expirationJob?.cancel() expirationJob = null - val nextExpiration = _pendingRequests.value.mapNotNull { it.expiresAt }.minOrNull() ?: return + val nextExpiration = (_pendingRequests.value + _sentRequests.value) + .mapNotNull { it.expiresAt } + .minOrNull() + ?: return val delayDuration = (nextExpiration - clock.now()).coerceAtLeast(Duration.ZERO) expirationJob = repoScope.launch { delay(delayDuration) @@ -210,9 +488,12 @@ class PaykitPaymentRequestRepo @Inject constructor( private val bitcoinAmountPattern = Regex("(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)") -@Suppress("ReturnCount") -private fun PaymentRequestRecord.toPaykitPaymentRequest(now: Instant): PaykitPaymentRequest? { - if (localRole != PaymentRequestLocalRole.PAYER || state != PaymentRequestLifecycleState.PROPOSED) return null +@Suppress("CyclomaticComplexMethod", "ReturnCount") +private fun PaymentRequestRecord.toPaykitPaymentRequest( + expectedRole: PaymentRequestLocalRole, + now: Instant, +): PaykitPaymentRequest? { + if (localRole != expectedRole || state != PaymentRequestLifecycleState.PROPOSED) return null val requestTerms = terms ?: return null if (requestTerms.recurrence != null || requestTerms.amount.asset != "btc") return null val amountSats = requestTerms.amount.value.toSats() @@ -234,11 +515,66 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest(now: Instant): PaykitPay counterpartyReceiverPath = counterpartyReceiverPath, amountValue = requestTerms.amount.value, amountSats = amountSats, + note = requestTerms.metadata.note(), + createdAt = lastEventAt?.let { runCatching { Instant.parse(it) }.getOrNull() }, expiresAt = expiresAt, acceptedPaymentEndpointIdentifiers = endpoints, + deliveryStatus = if (expectedRole == PaymentRequestLocalRole.PAYEE) { + if (proposalOutboundStatus == OutboundPrivateMessageStatus.SENT) { + PaykitPaymentRequestDeliveryStatus.Sent + } else { + PaykitPaymentRequestDeliveryStatus.Queued + } + } else { + null + }, + ) +} + +private fun PaymentRequestRecord.toCreatedPaykitPaymentRequest( + draft: PaykitPaymentRequestDraft, + target: PaykitPaymentRequestTarget, + endpointIdentifiers: List, + createdAt: Instant, + reports: List, +): PaykitPaymentRequest { + val wasSent = proposalOutboundMessageId?.let { messageId -> + reports.any { report -> + PubkyPublicKeyFormat.matches(report.counterparty, counterparty) && + report.counterpartyReceiverPath == counterpartyReceiverPath && + messageId in report.report?.sent.orEmpty() + } + } == true + return PaykitPaymentRequest( + paymentRequestId = paymentRequestId, + counterparty = target.publicKey, + counterpartyReceiverPath = target.receiverPath, + amountValue = draft.amountSats.toBitcoinAmount(), + amountSats = draft.amountSats, + note = draft.note.takeIf(String::isNotBlank), + createdAt = lastEventAt?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: createdAt, + expiresAt = draft.expiresAt, + acceptedPaymentEndpointIdentifiers = endpointIdentifiers, + deliveryStatus = if (wasSent) { + PaykitPaymentRequestDeliveryStatus.Sent + } else { + PaykitPaymentRequestDeliveryStatus.Queued + }, ) } +private fun PrivateJsonObject.note(): String? = runCatching { + Json.parseToJsonElement(exportText()) + .jsonObject["note"] + ?.jsonPrimitive + ?.contentOrNull + ?.trim() + ?.takeIf(String::isNotEmpty) +}.getOrNull() + +private fun ULong.toBitcoinAmount(): String = + BigDecimal(toString()).movePointLeft(8).stripTrailingZeros().toPlainString() + private fun String.toSats(): ULong? { if (!bitcoinAmountPattern.matches(this)) return null return runCatching { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 709aa9a22e..fc0ea04eef 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -394,6 +394,11 @@ class PrivatePaykitRepo @Inject constructor( (context.receiverPath to context.paymentListVersion) contactState.remoteEndpoints = emptyList() persistState(markWalletBackup = true) + Logger.info( + "Consumed private Paykit payment list version ${context.paymentListVersion} " + + "for '${redacted(normalizedKey)}'", + context = TAG, + ) } }.onFailure { Logger.warn("Failed to consume private Paykit payment details", it, context = TAG) @@ -556,6 +561,7 @@ class PrivatePaykitRepo @Inject constructor( publicKey = publicKey, receiverPath = receiverPath, resolution = resolution, + consumedVersion = consumedVersion, acceptedEndpointIdentifiers = paymentRequest?.acceptedPaymentEndpointIdentifiers?.toSet(), ) if (paymentRequest?.isExpired(clock.now()) == true) { @@ -621,6 +627,7 @@ class PrivatePaykitRepo @Inject constructor( publicKey: String, receiverPath: String, resolution: PaykitPrivateContactPaymentResolution, + consumedVersion: ULong?, acceptedEndpointIdentifiers: Set? = null, ): PublicPaykitPaymentResult { val privateEndpoints = resolution.payableEndpoints @@ -633,7 +640,11 @@ class PrivatePaykitRepo @Inject constructor( val privatePayable = privatePayableEndpoints(acceptedEndpoints, publicKey) val paymentListVersion = resolution.privatePaymentListVersion if (privatePayable.isNotEmpty() && paymentListVersion != null) { - Logger.info("Opened private Paykit payment for '${redacted(publicKey)}'", context = TAG) + Logger.info( + "Opened private Paykit payment for '${redacted(publicKey)}' using payment list version " + + "$paymentListVersion after ${consumedVersion ?: "none"}", + context = TAG, + ) return PublicPaykitPaymentResult.Opened( paymentRequest = PublicPaykitRepo.paymentRequest(privatePayable), privatePaymentContext = PrivatePaykitPaymentContext(receiverPath, paymentListVersion), @@ -650,6 +661,11 @@ class PrivatePaykitRepo @Inject constructor( ) } if (resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST) { + Logger.info( + "Waiting for a private Paykit payment list newer than ${consumedVersion ?: "none"} " + + "for '${redacted(publicKey)}'; public resolution is disabled for this request", + context = TAG, + ) return PublicPaykitPaymentResult.WaitingForUpdatedPaymentList } diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index be6db40cd4..a82a482f4b 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -538,10 +538,11 @@ class PubkyRepo @Inject constructor( withContext(ioDispatcher) { val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow() - val homegate = fetchHomegateSignupCode() + val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null } + ?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode } runSuspendCatching { - pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode) + pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second) }.getOrElse { Logger.warn("Retrying sign in after sign up failed", it, context = TAG) pubkyService.signIn(secretKeyHex) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 818eb5c9d1..e642d2a5ae 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -7,6 +7,7 @@ import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.EndpointSyncReport +import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.OutboundPrivateCounterpartySendReport @@ -20,9 +21,13 @@ import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitSdkDefaults import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PaymentPayload +import com.synonym.paykit.PaymentReference +import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PaymentTarget import com.synonym.paykit.PrivateContactPaymentResolution +import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PrivatePaymentEndpointCandidate import com.synonym.paykit.PrivatePaymentEndpointReservationCancellation import com.synonym.paykit.PrivatePaymentEndpointSelectionRequest @@ -107,6 +112,14 @@ data class PaykitResolvedPaymentEndpoint( val payload: String, ) +data class PaykitPaymentRequestProposalTerms( + val amountValue: String, + val paymentReference: String, + val proposalExpiresAt: String, + val acceptedPaymentEndpointIdentifiers: List, + val metadataJson: String, +) + data class PaykitPrivateReceiverPathSelection( val linkableReceiverPaths: List, val publishableReceiverPaths: List, @@ -119,7 +132,8 @@ internal object PaykitReceiverPaths { const val SERVER = "bitkit/server" /** Current Bitkit flows only route its own receivers; cross-wallet routing can broaden this allowlist. */ - val supported = setOf(WALLET, SERVER) + val ordered = listOf(WALLET, SERVER) + val supported = ordered.toSet() } @Singleton @@ -154,8 +168,22 @@ class PaykitSdkService @Inject constructor( try { PaykitAndroid.initializeOrThrow(context) operationMutex.withLock { - val handle = handle() - handle.initialize() + var handle = handle() + try { + handle.initialize() + } catch (e: PaykitException.Identity) { + if (!sessionProvider.canDeferStaleSession(e.context)) throw e + + Logger.warn("Deferring stale Paykit session restoration until SDK setup completes", e, context = TAG) + sessionProvider.suspendStoredSessionAccess() + resetRuntime() + try { + handle = handle() + handle.initialize() + } finally { + sessionProvider.resumeStoredSessionAccess() + } + } publishReceiverMarkerIfLiveSessionAvailable(handle) } isSetup.complete(Unit) @@ -179,7 +207,7 @@ class PaykitSdkService @Inject constructor( suspend fun hasPrivatePaymentAccess(): Boolean { isSetup.await() return operationMutex.withLock { - sessionProvider.hasSessionAccess() + handle().identityStatus()?.liveSessionAvailable == true } } @@ -474,7 +502,7 @@ class PaykitSdkService @Inject constructor( return@withStateRevisionTracking } - handle.publishPaykitReceiverMarker(receiverCapabilities()) + handle.publishPaykitReceiverMarker(receiverCapabilities(handle)) } } } @@ -555,6 +583,66 @@ class PaykitSdkService @Inject constructor( } } + suspend fun paymentRequests(): List { + isSetup.await() + return operationMutex.withLock { + handle().listPaymentRequests( + com.synonym.paykit.PaymentRequestFilter( + counterparty = null, + counterpartyReceiverPath = null, + localRole = null, + states = emptyList(), + recurring = null, + receivedOnly = false, + ) + ) + } + } + + suspend fun identityStatus(): IdentityStatus? { + isSetup.await() + return operationMutex.withLock { + handle().identityStatus() + } + } + + suspend fun paymentRequestReceiverPaths(publicKey: String): List { + isSetup.await() + return operationMutex.withLock { + val handle = handle() + handle.paykitReceiverPaths(publicKey) + .filter { it in PaykitReceiverPaths.supported } + .filter { handle.paykitReceiverMarker(publicKey, it)?.capabilities?.paymentRequests == true } + } + } + + suspend fun proposePaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + proposal: PaykitPaymentRequestProposalTerms, + expectedIdentity: String, + ): PaymentRequestRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + val identityStatus = handle.identityStatus() + check( + identityStatus?.liveSessionAvailable == true && + PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) + ) { "Paykit identity changed before proposing the payment request" } + val terms = PaymentRequestTerms( + amount = PaymentRequestAmount(proposal.amountValue, "btc"), + paymentReference = PaymentReference(proposal.paymentReference), + proposalExpiresAt = proposal.proposalExpiresAt, + recurrence = null, + acceptedPaymentEndpointIdentifiers = proposal.acceptedPaymentEndpointIdentifiers, + metadata = PrivateJsonObject(proposal.metadataJson), + ) + handle.proposePaymentRequest(counterparty, counterpartyReceiverPath, terms) + } + } + } + suspend fun acceptPaymentRequest( counterparty: String, counterpartyReceiverPath: String, @@ -568,6 +656,20 @@ class PaykitSdkService @Inject constructor( } } + suspend fun rejectPaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String, + reason: String? = null, + ): PaymentRequestRecord { + isSetup.await() + return operationMutex.withLock { + withStateRevisionTracking { handle -> + handle.rejectPaymentRequest(counterparty, counterpartyReceiverPath, paymentRequestId, reason) + } + } + } + suspend fun linkedPeers(): List { isSetup.await() return operationMutex.withLock { @@ -743,7 +845,7 @@ class PaykitSdkService @Inject constructor( private suspend fun publishReceiverMarkerIfLiveSessionAvailable(handle: PaykitSdk) { runSuspendCatching { - val capabilities = receiverCapabilities() + val capabilities = receiverCapabilities(handle) if (capabilities.privatePayments) { handle.publishPaykitReceiverMarker(capabilities) } @@ -752,8 +854,8 @@ class PaykitSdkService @Inject constructor( } } - private fun receiverCapabilities(): PaykitReceiverCapabilities { - val hasPrivatePaymentAccess = sessionProvider.hasSessionAccess() + private suspend fun receiverCapabilities(handle: PaykitSdk): PaykitReceiverCapabilities { + val hasPrivatePaymentAccess = handle.identityStatus()?.liveSessionAvailable == true return PaykitReceiverCapabilities( privatePayments = hasPrivatePaymentAccess, paymentRequests = hasPrivatePaymentAccess, @@ -906,6 +1008,7 @@ internal class PaykitSdkSessionProvider( private val lock = Any() private val receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore(keychain) private var liveSessionAccess: PubkySessionAccess? = null + private var isStoredSessionAccessSuspended = false fun setLiveSessionAccess(access: PubkySessionAccess) = synchronized(lock) { liveSessionAccess = access @@ -916,21 +1019,22 @@ internal class PaykitSdkSessionProvider( } override fun loadSessionAccess(): PubkySessionAccess? { - val sessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) - ?.takeIf { it.isNotBlank() } - ?: return null + return synchronized(lock) { + if (isStoredSessionAccessSuspended) return null - synchronized(lock) { + val sessionSecret = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) + ?.takeIf { it.isNotBlank() } + ?: return null liveSessionAccess ?.takeIf { it.exportSessionSecret() == sessionSecret } ?.let { return it } - } - return PubkySessionAccess( - sessionSecret = sessionSecret, - localSecretKey = loadLocalSecretKey(), - receiverNoiseSecretKey = loadOrDeriveReceiverNoiseSecretKey(), - ) + PubkySessionAccess( + sessionSecret = sessionSecret, + localSecretKey = loadLocalSecretKey(), + receiverNoiseSecretKey = loadOrDeriveReceiverNoiseSecretKey(), + ) + } } override fun publicStorageAvailable(): Boolean = true @@ -938,6 +1042,18 @@ internal class PaykitSdkSessionProvider( fun hasSessionAccess(): Boolean = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)?.isNotBlank() == true + fun canDeferStaleSession(errorContext: String): Boolean = + errorContext == STALE_SESSION_IMPORT_CONTEXT && hasSessionAccess() + + fun suspendStoredSessionAccess() = synchronized(lock) { + liveSessionAccess = null + isStoredSessionAccessSuspended = true + } + + fun resumeStoredSessionAccess() = synchronized(lock) { + isStoredSessionAccessSuspended = false + } + override fun clearSessionAccess() { clearLiveSessionAccess() keychain.accessBlocking { @@ -946,6 +1062,10 @@ internal class PaykitSdkSessionProvider( } } + private companion object { + const val STALE_SESSION_IMPORT_CONTEXT = "import Pubky session from platform provider" + } + fun loadLocalSecretKey(): PubkyLocalSecretKey? { val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) ?.takeIf { it.isNotBlank() } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index ba977db73c..baa2b1e79c 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -91,6 +91,8 @@ import to.bitkit.ui.screens.contacts.ContactsViewModel import to.bitkit.ui.screens.contacts.EditContactScreen import to.bitkit.ui.screens.contacts.EditContactViewModel import to.bitkit.ui.screens.contacts.shouldDiscardPendingImport +import to.bitkit.ui.screens.paymentrequests.PaymentRequestsScreen +import to.bitkit.ui.screens.paymentrequests.PaymentRequestsSheet import to.bitkit.ui.screens.profile.CreateProfileScreen import to.bitkit.ui.screens.profile.CreateProfileViewModel import to.bitkit.ui.screens.profile.EditProfileScreen @@ -451,6 +453,7 @@ fun ContentView( val isPaykitEnabled by settingsViewModel.isPaykitEnabled.collectAsStateWithLifecycle() val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() + val isCreatingPaymentRequest by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() var homeWalletPageRequest by remember { mutableIntStateOf(0) } var homeWidgetsPageRequest by remember { mutableIntStateOf(0) } val navigateToHomeWallet = { @@ -474,6 +477,9 @@ fun ContentView( SheetHost( shouldExpand = currentSheet != null, onDismiss = { appViewModel.hideSheet() }, + visibilityKey = currentSheet, + onVisible = { appViewModel.onSheetVisible(currentSheet) }, + dismissEnabled = !isCreatingPaymentRequest, sheetHandlePlacement = when (currentSheet) { is Sheet.Widgets -> SheetHandlePlacement.ContentOverlay else -> SheetHandlePlacement.ScaffoldSlot @@ -497,6 +503,7 @@ fun ContentView( val walletState by walletViewModel.walletState.collectAsStateWithLifecycle() val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() ReceiveSheet( + appViewModel = appViewModel, startRoute = sheet.route, walletState = walletState, isOffline = connectivityState != ConnectivityState.CONNECTED, @@ -507,6 +514,15 @@ fun ContentView( ) } + Sheet.PaymentRequests -> PaymentRequestsSheet( + appViewModel = appViewModel, + onNotNow = appViewModel::hideSheet, + onSeeAll = { + appViewModel.hideSheet() + navController.navigateTo(Routes.PaymentRequests) + }, + ) + is Sheet.ActivityDateRangeSelector -> DateRangeSelectorSheet() is Sheet.ActivityTagSelector -> TagSelectorSheet() is Sheet.Pin -> PinSheet(sheet, appViewModel) @@ -711,6 +727,12 @@ private fun RootNavHost( activityListViewModel = activityListViewModel, navController = navController, ) + composableWithDefaultTransitions { + PaymentRequestsScreen( + appViewModel = appViewModel, + onBack = { navController.popBackStack() }, + ) + } settings(navController, settingsViewModel) contacts(navController, settingsViewModel, appViewModel) profile(navController, settingsViewModel) @@ -2293,6 +2315,9 @@ sealed interface Routes { @Serializable data object AllActivity : Routes.DeepLinkable + @Serializable + data object PaymentRequests : Routes.InternalOnly + @Serializable data object Trezor : Routes.DeepLinkable } 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 50e2999dd1..2358169182 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -1,12 +1,17 @@ package to.bitkit.ui.components +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.PrimaryDisplay import to.bitkit.models.STUB_RATE @@ -25,10 +30,12 @@ import java.math.BigDecimal fun MoneyDisplay( sats: Long, onClick: (() -> Unit)? = null, + showSymbol: Boolean? = null, ) { - rememberMoneyText(sats)?.let { text -> + val text = if (showSymbol == null) rememberMoneyText(sats) else rememberMoneyText(sats, showSymbol = showSymbol) + text?.let { Display( - text = text.withAccent(accentColor = Colors.White64), + text = it.withAccent(accentColor = Colors.White64), modifier = Modifier .clickableAlpha(onClick = onClick) .testTag("MoneyText") @@ -36,6 +43,27 @@ fun MoneyDisplay( } } +@Composable +fun MoneyStack( + sats: Long, + modifier: Modifier = Modifier, +) { + val currencies = LocalCurrencies.current + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.Start, + modifier = modifier.fillMaxWidth(), + ) { + MoneySSB( + sats = sats, + unit = currencies.primaryDisplay.not(), + color = Colors.White64, + showSymbol = true, + ) + MoneyDisplay(sats = sats, showSymbol = true) + } +} + @Composable fun MoneySSB( sats: Long, 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 d1b6c8d835..961637aa32 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -23,10 +23,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import kotlinx.coroutines.launch @@ -54,6 +56,7 @@ enum class SheetHandlePlacement { sealed interface Sheet { data class Send(val route: SendRoute = SendRoute.Recipient) : Sheet data class Receive(val route: ReceiveRoute = ReceiveRoute.QR) : Sheet + data object PaymentRequests : Sheet data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet data object ChangePin : Sheet data object DisablePin : Sheet @@ -91,16 +94,25 @@ enum class TimedSheetType(val priority: Int) { fun SheetHost( shouldExpand: Boolean, onDismiss: () -> Unit = {}, + visibilityKey: Any? = null, + onVisible: () -> Unit = {}, + dismissEnabled: Boolean = true, sheetHandlePlacement: SheetHandlePlacement = SheetHandlePlacement.ScaffoldSlot, sheetContainerColor: Color = DefaultSheetContainerColor, sheets: @Composable ColumnScope.() -> Unit, content: @Composable () -> Unit, ) { val scope = rememberCoroutineScope() + val currentDismissEnabled by rememberUpdatedState(dismissEnabled) + val currentShouldExpand by rememberUpdatedState(shouldExpand) val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + bottomSheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { currentDismissEnabled || !currentShouldExpand || it != SheetValue.Hidden }, + ) ) var wasSheetVisible by remember { mutableStateOf(false) } + var visibleKey by remember { mutableStateOf(null) } // Automatically expand or hide the bottom sheet based on bool flag LaunchedEffect(shouldExpand) { @@ -111,11 +123,16 @@ fun SheetHost( } } - LaunchedEffect(scaffoldState.bottomSheetState.isVisible) { + LaunchedEffect(scaffoldState.bottomSheetState.isVisible, visibilityKey) { if (scaffoldState.bottomSheetState.isVisible) { wasSheetVisible = true + if (visibleKey != visibilityKey) { + visibleKey = visibilityKey + onVisible() + } } else if (wasSheetVisible) { wasSheetVisible = false + visibleKey = null onDismiss() } } @@ -144,13 +161,15 @@ fun SheetHost( // Dismiss on back BackHandler(enabled = scaffoldState.bottomSheetState.isVisible) { - scope.launch { - scaffoldState.bottomSheetState.hide() - onDismiss() + if (dismissEnabled) { + scope.launch { + scaffoldState.bottomSheetState.hide() + onDismiss() + } } } - Scrim(scaffoldState.bottomSheetState) { + Scrim(scaffoldState.bottomSheetState, enabled = dismissEnabled) { scope.launch { scaffoldState.bottomSheetState.hide() onDismiss() @@ -181,6 +200,7 @@ private fun OverlayHandleSheetContent( @OptIn(ExperimentalMaterial3Api::class) private fun Scrim( bottomSheetState: SheetState, + enabled: Boolean, onClick: () -> Unit, ) { val isBottomSheetVisible = bottomSheetState.targetValue != SheetValue.Hidden @@ -190,11 +210,22 @@ private fun Scrim( label = "sheetScrimAlpha" ) if (scrimAlpha > 0f || isBottomSheetVisible) { + val interactionModifier = if (enabled) { + Modifier.clickableAlpha(pressedAlpha = 1f, onClick = onClick) + } else { + Modifier.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent().changes.forEach { it.consume() } + } + } + } + } Box( modifier = Modifier .fillMaxSize() .background(Colors.Black.copy(alpha = scrimAlpha)) - .clickableAlpha(pressedAlpha = 1f, onClick = onClick) + .then(interactionModifier) ) } } 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 new file mode 100644 index 0000000000..31534b4ce0 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -0,0 +1,346 @@ +@file:OptIn(ExperimentalTime::class) +@file:Suppress("MatchingDeclarationName") + +package to.bitkit.ui.screens.paymentrequests + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDraft +import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.MoneyStack +import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.NumberPadTextField +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AmountInputViewModel +import to.bitkit.viewmodels.AppViewModel +import kotlin.math.abs +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +enum class PaymentRequestExpiration(val duration: Duration) { + Hour(1.hours), + Day(1.days), + Week(7.days), + Month(30.days); + + companion object { + fun from(expiration: Instant, now: Instant): PaymentRequestExpiration { + if (expiration <= now) return Week + return entries.minBy { + abs(((now + it.duration) - expiration).inWholeMilliseconds) + } + } + } +} + +@Composable +fun PaymentRequestDetailsScreen( + amountInputViewModel: AmountInputViewModel, + initialDraft: PaykitPaymentRequestDraft, + onBack: () -> Unit, + onContinue: (PaykitPaymentRequestDraft) -> Unit, +) { + val currencies = LocalCurrencies.current + val amountState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + var note by remember(initialDraft.note) { mutableStateOf(initialDraft.note) } + var expiration by remember(initialDraft.expiresAt) { + mutableStateOf(PaymentRequestExpiration.from(initialDraft.expiresAt, Clock.System.now())) + } + + LaunchedEffect(initialDraft.amountSats) { + amountInputViewModel.setSats( + initialDraft.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + currencies, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("PaymentRequestDetails") + ) { + SheetTopBar( + titleText = stringResource(R.string.wallet__payment_request), + onBack = onBack, + ) + Caption13Up(text = stringResource(R.string.wallet__payment_request_amount), color = Colors.White64) + Spacer(Modifier.height(8.dp)) + NumberPadTextField( + viewModel = amountInputViewModel, + modifier = Modifier.testTag("PaymentRequestAmountField"), + ) + Spacer(Modifier.height(20.dp)) + Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) + Spacer(Modifier.height(8.dp)) + TextInput( + value = note, + onValueChange = { note = it.take(256) }, + placeholder = stringResource(R.string.wallet__payment_request_note_placeholder), + maxLines = 2, + modifier = Modifier + .fillMaxWidth() + .testTag("PaymentRequestNote"), + ) + Spacer(Modifier.height(20.dp)) + Caption13Up(text = stringResource(R.string.wallet__payment_request_expires), color = Colors.White64) + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + PaymentRequestExpiration.entries.forEach { option -> + val isSelected = option == expiration + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .clickableAlpha { expiration = option } + .semantics { + role = Role.RadioButton + selected = isSelected + } + .testTag("PaymentRequestExpiry_${option.name}"), + ) { + BodyS(text = option.title(), color = if (isSelected) Colors.White else Colors.White64) + Spacer(Modifier.height(8.dp)) + HorizontalDivider( + thickness = 2.dp, + color = if (isSelected) Colors.Purple else Colors.White16, + ) + } + } + } + Spacer(Modifier.weight(1f)) + NumberPad( + viewModel = amountInputViewModel, + availableHeight = 210.dp, + modifier = Modifier.testTag("PaymentRequestNumberPad"), + ) + Spacer(Modifier.height(12.dp)) + PrimaryButton( + text = stringResource(R.string.wallet__payment_request_choose_recipient), + enabled = amountState.sats > 0, + onClick = { + onContinue( + PaykitPaymentRequestDraft( + amountSats = amountState.sats.toULong(), + note = note.trim(), + expiresAt = Clock.System.now() + expiration.duration, + ) + ) + }, + modifier = Modifier.testTag("PaymentRequestAmountContinue"), + ) + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +fun PaymentRequestRecipientScreen( + appViewModel: AppViewModel, + draft: PaykitPaymentRequestDraft, + onBack: () -> Unit, + onSent: (PaykitPaymentRequest) -> Unit, +) { + val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + var selectedTarget by remember { mutableStateOf(null) } + + LaunchedEffect(targets) { + if (selectedTarget !in targets) selectedTarget = null + } + BackHandler(enabled = isCreating) {} + + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("PaymentRequestRecipient") + ) { + SheetTopBar( + titleText = stringResource(R.string.wallet__payment_request_choose_recipient), + onBack = if (isCreating) null else onBack, + ) + LazyColumn(modifier = Modifier.weight(1f)) { + items(targets, key = { "${it.publicKey}|${it.receiverPath}" }) { target -> + val contact = contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, target.publicKey) } + ?: PubkyProfile.placeholder(target.publicKey) + PaymentRequestContactRow( + contact = contact, + selected = target == selectedTarget, + enabled = !isCreating, + onClick = { selectedTarget = target }, + ) + HorizontalDivider(color = Colors.White10) + } + } + PrimaryButton( + text = stringResource(R.string.wallet__payment_request_send_request), + enabled = !isCreating && selectedTarget != null && selectedTarget in targets, + isLoading = isCreating, + onClick = { + val target = selectedTarget ?: return@PrimaryButton + appViewModel.createPaymentRequest(draft, target, onSent) + }, + modifier = Modifier.testTag("PaymentRequestSend"), + ) + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun PaymentRequestContactRow( + contact: PubkyProfile, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .clickableAlpha(enabled = enabled, onClick = onClick) + .semantics { + role = Role.RadioButton + this.selected = selected + if (!enabled) disabled() + } + .padding(vertical = 16.dp) + .testTag("PaymentRequestContact_${contact.publicKey}"), + ) { + PubkyContactAvatar(profile = contact) + Column(modifier = Modifier.weight(1f)) { + BodyS( + text = contact.truncatedPublicKey, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodySSB(text = contact.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (selected) { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = null, + tint = Colors.Purple, + modifier = Modifier.size(24.dp), + ) + } + } +} + +@Composable +fun PaymentRequestSentScreen( + request: PaykitPaymentRequest, + onDone: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("PaymentRequestSent"), + ) { + SheetTopBar(titleText = stringResource(R.string.wallet__payment_request_sent_title)) + MoneyStack(sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()) + Spacer(Modifier.weight(1f)) + Image( + painter = painterResource(R.drawable.check), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(24.dp)) + Display(text = stringResource(R.string.wallet__payment_request_sent_headline).withAccent()) + Spacer(Modifier.height(12.dp)) + BodyM( + text = if (request.deliveryStatus == to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus.Sent) { + stringResource(R.string.wallet__payment_request_sent_description) + } else { + stringResource(R.string.wallet__payment_request_queued_description) + }, + color = Colors.White64, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.weight(1f)) + PrimaryButton( + text = stringResource(R.string.common__ok), + onClick = onDone, + ) + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private 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 + PaymentRequestExpiration.Week -> R.string.wallet__payment_request_expiry_week + PaymentRequestExpiration.Month -> R.string.wallet__payment_request_expiry_month + } +) diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt new file mode 100644 index 0000000000..0b32238382 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -0,0 +1,317 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.paymentrequests + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.ext.formatInvoiceExpiryRelative +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.AppViewModel +import java.text.DateFormat +import java.util.Date +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@Composable +fun PaymentRequestsSheet( + appViewModel: AppViewModel, + onNotNow: () -> Unit, + onSeeAll: () -> Unit, +) { + val requests by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + + LaunchedEffect(requests.isEmpty()) { + if (requests.isEmpty()) onNotNow() + } + + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("PaymentRequestsSheet") + ) { + SheetTopBar(titleText = stringResource(R.string.wallet__payment_requests)) + BodyM( + text = stringResource(R.string.wallet__payment_requests_review), + color = Colors.White64, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.height(24.dp)) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.weight(1f), + ) { + items(requests.take(3), key = { it.lazyListKey }) { request -> + PaymentRequestCard( + request = request, + contact = contacts.contactFor(request), + onPay = { appViewModel.openIncomingPaymentRequest(request.id) }, + onReject = { appViewModel.rejectIncomingPaymentRequest(request) }, + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + SecondaryButton( + text = stringResource(R.string.wallet__payment_requests_not_now), + onClick = onNotNow, + modifier = Modifier.weight(1f), + ) + PrimaryButton( + text = stringResource(R.string.wallet__payment_requests_see_all), + onClick = onSeeAll, + modifier = Modifier + .weight(1f) + .testTag("PaymentRequestsSeeAll"), + ) + } + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +fun PaymentRequestsScreen( + appViewModel: AppViewModel, + onBack: () -> Unit, +) { + val incoming by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() + val sent by appViewModel.sentPaymentRequests.collectAsStateWithLifecycle() + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("PaymentRequestsScreen") + ) { + AppTopBar( + titleText = stringResource(R.string.wallet__payment_requests), + onBackClick = onBack, + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + if (incoming.isEmpty() && sent.isEmpty()) { + item { + BodyM( + text = stringResource(R.string.wallet__payment_requests_empty), + color = Colors.White64, + modifier = Modifier + .fillMaxWidth() + .padding(top = 120.dp), + ) + } + } + if (incoming.isNotEmpty()) { + item { + Caption13Up( + text = stringResource(R.string.wallet__payment_requests_incoming), + color = Colors.White64, + ) + } + items(incoming, key = { it.lazyListKey }) { request -> + PaymentRequestCard( + request = request, + contact = contacts.contactFor(request), + onPay = { appViewModel.openIncomingPaymentRequest(request.id) }, + onReject = { appViewModel.rejectIncomingPaymentRequest(request) }, + ) + } + } + if (sent.isNotEmpty()) { + item { + Caption13Up( + text = stringResource(R.string.wallet__payment_requests_sent), + color = Colors.White64, + ) + } + items(sent, key = { it.lazyListKey }) { request -> + PaymentRequestCard( + request = request, + contact = contacts.contactFor(request), + status = if (request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + stringResource(R.string.wallet__payment_request_waiting) + } else { + stringResource(R.string.wallet__payment_request_sending) + }, + ) + } + } + item { Spacer(Modifier.height(96.dp)) } + } + } +} + +@Composable +private fun PaymentRequestCard( + request: PaykitPaymentRequest, + contact: PubkyProfile?, + status: String? = null, + onPay: (() -> Unit)? = null, + onReject: (suspend () -> Result)? = null, +) { + val scope = rememberCoroutineScope() + var isRejecting by remember(request.id) { mutableStateOf(false) } + val displayContact = contact ?: PubkyProfile.placeholder(request.counterparty) + val subtitle = remember(request.createdAt, displayContact.name) { + request.createdAt?.let { + val timestamp = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + .format(Date(it.toEpochMilliseconds())) + "${displayContact.name} · $timestamp" + } ?: displayContact.name + } + + Card( + colors = CardDefaults.cardColors(containerColor = Colors.Gray6), + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, Colors.Purple.copy(alpha = 0.32f), MaterialTheme.shapes.medium) + .testTag("PaymentRequestRow_${request.paymentRequestId}"), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(16.dp), + ) { + PubkyContactAvatar(profile = displayContact, size = 40.dp) + Column(modifier = Modifier.weight(1f)) { + BodyMSB( + text = request.note ?: stringResource(R.string.wallet__payment_request), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodyS( + text = subtitle, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + MoneySSB( + sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + showSymbol = true, + ) + } + if (status != null) { + HorizontalDivider(color = Colors.White10) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + BodyS(text = status, color = Colors.Purple) + Spacer(Modifier.weight(1f)) + request.expiresAt?.let { + val remainingSeconds = (it - Clock.System.now()).inWholeSeconds.coerceAtLeast(0).toULong() + BodyS(text = formatInvoiceExpiryRelative(remainingSeconds), color = Colors.White64) + } + } + } else if (onPay != null || onReject != null) { + HorizontalDivider(color = Colors.White10) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(16.dp), + ) { + SecondaryButton( + text = stringResource(R.string.wallet__payment_request_reject), + onClick = { + if (isRejecting || onReject == null) return@SecondaryButton + isRejecting = true + scope.launch { + onReject() + isRejecting = false + } + }, + isLoading = isRejecting, + enabled = !isRejecting, + icon = { + Icon( + painter = painterResource(R.drawable.ic_x), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + size = ButtonSize.Small, + modifier = Modifier.weight(1f), + ) + PrimaryButton( + text = stringResource(R.string.wallet__payment_request_pay), + onClick = { onPay?.invoke() }, + enabled = !isRejecting, + icon = { + Icon( + painter = painterResource(R.drawable.ic_coins), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + size = ButtonSize.Small, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +private fun List.contactFor(request: PaykitPaymentRequest): PubkyProfile? = + firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, request.counterparty) } + +private val PaykitPaymentRequest.lazyListKey: String + get() = "$paymentRequestId|$counterparty|$counterpartyReceiverPath" diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 2f9f780e8c..de2f0431c0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -82,6 +82,9 @@ import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.tooling.preview.Devices.PIXEL_TABLET import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -132,6 +135,7 @@ import to.bitkit.ui.Routes import to.bitkit.ui.components.ActivityBanner import to.bitkit.ui.components.AppStatus import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.EmptyStateView import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth @@ -198,6 +202,7 @@ import to.bitkit.viewmodels.WalletViewModel private const val SMALL_SCREEN_HEIGHT_DP = 800 private const val SMALL_SCREEN_SLOT_CAPACITY = 3 private const val LARGE_SCREEN_SLOT_CAPACITY = 4 +private const val MAX_PAYMENT_REQUEST_BADGE_COUNT = 99 private val CALCULATOR_LIFT_SPEC = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow, @@ -235,6 +240,8 @@ fun HomeScreen( val quickPayIntroSeen by settingsViewModel.quickPayIntroSeen.collectAsStateWithLifecycle() val latestActivities by activityListViewModel.latestActivities.collectAsStateWithLifecycle() val hardwareIds by activityListViewModel.hardwareIds.collectAsStateWithLifecycle() + val pendingPaymentRequests by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() + val sentPaymentRequests by appViewModel.sentPaymentRequests.collectAsStateWithLifecycle() val homeUiState by homeViewModel.uiState.collectAsStateWithLifecycle() @@ -272,7 +279,18 @@ fun HomeScreen( profileDisplayName = profileDisplayName, profileDisplayImageUri = profileDisplayImageUri, showProfileButton = isPaykitEnabled, + showPaymentRequests = isPaykitEnabled && ( + pendingPaymentRequests.isNotEmpty() || sentPaymentRequests.isNotEmpty() + ), + pendingPaymentRequestCount = pendingPaymentRequests.size, onClickProfile = navigateToProfile, + onClickPaymentRequests = { + if (pendingPaymentRequests.isNotEmpty()) { + appViewModel.showPaymentRequests() + } else { + rootNavController.navigateTo(Routes.PaymentRequests) + } + }, latestActivities = latestActivities, hardwareIds = hardwareIds, onRefresh = { @@ -397,7 +415,10 @@ private fun Content( profileDisplayName: String? = null, profileDisplayImageUri: String? = null, showProfileButton: Boolean = false, + showPaymentRequests: Boolean = false, + pendingPaymentRequestCount: Int = 0, onClickProfile: () -> Unit = {}, + onClickPaymentRequests: () -> Unit = {}, latestActivities: ImmutableList?, hardwareIds: ImmutableSet = persistentSetOf(), onRefresh: () -> Unit = {}, @@ -494,11 +515,18 @@ private fun Content( profileDisplayName = profileDisplayName, profileDisplayImageUri = profileDisplayImageUri, showProfileButton = showProfileButton, + showPaymentRequests = showPaymentRequests, + pendingPaymentRequestCount = pendingPaymentRequestCount, onClickProfile = { dismissKeyboard { onClickProfile() } }, + onClickPaymentRequests = { + dismissKeyboard { + onClickPaymentRequests() + } + }, showEditWidgets = homeUiState.currentPage == 1 && homeUiState.showWidgets, isEditingWidgets = homeUiState.isEditingWidgets, onClickEditWidgetList = { @@ -1303,6 +1331,9 @@ private fun TopBar( profileDisplayImageUri: String? = null, showProfileButton: Boolean = false, onClickProfile: () -> Unit = {}, + showPaymentRequests: Boolean = false, + pendingPaymentRequestCount: Int = 0, + onClickPaymentRequests: () -> Unit = {}, showEditWidgets: Boolean = false, isEditingWidgets: Boolean = false, onClickEditWidgetList: () -> Unit = {}, @@ -1348,6 +1379,61 @@ private fun TopBar( AppStatus( onClick = onNavigateToAppStatus, ) + if (showPaymentRequests) { + val paymentRequestsDescription = stringResource(R.string.wallet__payment_requests) + val pendingRequestsDescription = if (pendingPaymentRequestCount > 0) { + stringResource( + R.string.wallet__payment_requests_pending_count, + pendingPaymentRequestCount, + ) + } else { + null + } + IconButton( + onClick = onClickPaymentRequests, + modifier = Modifier + .semantics { + contentDescription = paymentRequestsDescription + pendingRequestsDescription?.let { stateDescription = it } + } + .testTag("PaymentRequestsBell"), + ) { + Box { + Icon( + painter = painterResource(R.drawable.ic_bell), + tint = Colors.Purple, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + if (pendingPaymentRequestCount > 0) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.TopEnd) + .size( + if (pendingPaymentRequestCount > MAX_PAYMENT_REQUEST_BADGE_COUNT) { + 22.dp + } else { + 18.dp + } + ) + .clip(CircleShape) + .background(Colors.Purple) + .testTag("PaymentRequestsBadge"), + ) { + BodySSB( + text = if (pendingPaymentRequestCount > MAX_PAYMENT_REQUEST_BADGE_COUNT) { + "$MAX_PAYMENT_REQUEST_BADGE_COUNT+" + } else { + pendingPaymentRequestCount.toString() + }, + color = Colors.Black, + ) + } + } + } + } + } IconButton( onClick = onOpenDrawer, modifier = Modifier.testTag("HeaderMenu") diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 289a6f2587..ee0a8b45dc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -91,6 +91,8 @@ fun ReceiveQrScreen( lightningState: LightningState, onClickEditInvoice: () -> Unit, onClickReceiveCjit: () -> Unit, + showPaymentRequestButton: Boolean = false, + onClickPaymentRequest: () -> Unit = {}, modifier: Modifier = Modifier, initialTab: ReceiveTab? = null, ) { @@ -290,6 +292,17 @@ fun ReceiveQrScreen( VerticalSpacer(24.dp) val showCjitButton = showingCjitOnboarding && selectedTab == ReceiveTab.SPENDING + if (showPaymentRequestButton && !showCjitButton) { + TertiaryButton( + text = stringResource(R.string.wallet__payment_request_send), + onClick = onClickPaymentRequest, + fullWidth = true, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("PaymentRequestSendButton"), + ) + VerticalSpacer(12.dp) + } val buttonVariant = when { showCjitButton -> BottomButtonVariant.CJIT showDetails -> BottomButtonVariant.SHOW_QR diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index e4a4bcc760..cfc5a8a8d5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -24,10 +25,15 @@ import androidx.navigation.compose.rememberNavController import kotlinx.serialization.Serializable import to.bitkit.R import to.bitkit.repositories.LightningState +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.WalletState import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.navigateTo import to.bitkit.ui.openNotificationSettings +import to.bitkit.ui.screens.paymentrequests.PaymentRequestDetailsScreen +import to.bitkit.ui.screens.paymentrequests.PaymentRequestRecipientScreen +import to.bitkit.ui.screens.paymentrequests.PaymentRequestSentScreen import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.utils.ScreenDeepLinks @@ -35,15 +41,22 @@ import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.utils.rememberNotificationToggleClick import to.bitkit.ui.walletViewModel import to.bitkit.viewmodels.AmountInputViewModel +import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.SettingsViewModel +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) @Composable fun ReceiveSheet( + appViewModel: AppViewModel, navigateToExternalConnection: () -> Unit, walletState: WalletState, isOffline: Boolean, startRoute: ReceiveRoute = ReceiveRoute.QR, editInvoiceAmountViewModel: AmountInputViewModel = hiltViewModel(), + paymentRequestAmountViewModel: AmountInputViewModel = hiltViewModel(key = "PaymentRequestAmount"), settingsViewModel: SettingsViewModel = hiltViewModel(), ) { val wallet = requireNotNull(walletViewModel) @@ -55,6 +68,17 @@ fun ReceiveSheet( val showCreateCjit = remember { mutableStateOf(false) } val cjitEntryDetails = remember { mutableStateOf(null) } val lightningState: LightningState by wallet.lightningState.collectAsStateWithLifecycle() + val paymentRequestTargets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() + var paymentRequestDraft by remember { + mutableStateOf( + PaykitPaymentRequestDraft( + amountSats = walletState.bip21AmountSats ?: 0uL, + note = walletState.bip21Description, + expiresAt = Clock.System.now() + 7.days, + ) + ) + } + var createdPaymentRequest by remember { mutableStateOf(null) } LaunchedEffect(Unit) { wallet.resetPreActivityMetadataTagsForCurrentInvoice() @@ -100,8 +124,47 @@ fun ReceiveSheet( } }, onClickEditInvoice = { navController.navigateTo(ReceiveRoute.EditInvoice) }, + showPaymentRequestButton = paymentRequestTargets.isNotEmpty(), + onClickPaymentRequest = { + paymentRequestDraft = PaykitPaymentRequestDraft( + amountSats = walletState.bip21AmountSats ?: 0uL, + note = walletState.bip21Description, + expiresAt = Clock.System.now() + 7.days, + ) + navController.navigateTo(ReceiveRoute.PaymentRequestDetails) + }, + ) + } + composableWithDefaultTransitions { + PaymentRequestDetailsScreen( + amountInputViewModel = paymentRequestAmountViewModel, + initialDraft = paymentRequestDraft, + onBack = { navController.popBackStack() }, + onContinue = { + paymentRequestDraft = it + navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) + }, + ) + } + composableWithDefaultTransitions { + PaymentRequestRecipientScreen( + appViewModel = appViewModel, + draft = paymentRequestDraft, + onBack = { navController.popBackStack() }, + onSent = { + createdPaymentRequest = it + navController.navigateTo(ReceiveRoute.PaymentRequestSent) + }, ) } + composableWithDefaultTransitions { + createdPaymentRequest?.let { + PaymentRequestSentScreen( + request = it, + onDone = appViewModel::hideSheet, + ) + } + } composableWithDefaultTransitions { ReceiveAmountScreen( onCjitCreated = { entry -> @@ -259,6 +322,15 @@ sealed interface ReceiveRoute { @Serializable data object AddTag : DeepLinkStart + @Serializable + data object PaymentRequestDetails : InternalOnly + + @Serializable + data object PaymentRequestRecipient : InternalOnly + + @Serializable + data object PaymentRequestSent : InternalOnly + @Serializable data object GeoBlock : DeepLinkStart diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 4b71ee0f05..b2742e06f1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -143,9 +143,11 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification import to.bitkit.repositories.PendingPaymentRepo @@ -288,7 +290,13 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() + val pendingPaymentRequests = paykitPaymentRequestRepo.pendingRequests + val sentPaymentRequests = paykitPaymentRequestRepo.sentRequests + val eligiblePaymentRequestTargets = paykitPaymentRequestRepo.eligibleTargets + val isCreatingPaymentRequest = paykitPaymentRequestRepo.isCreatingRequest + val pubkyContacts = pubkyRepo.contacts private var sheetTransitionJob: Job? = null + private var paymentRequestSheetTransitionJob: Job? = null private var queuedPairingCodeRequestId: Long? = null private var receiveSheetContext: ReceiveSheetContext? = null @@ -303,8 +311,12 @@ class AppViewModel @Inject constructor( private val contactPaymentContextLock = Any() private var activeContactPaymentContext: ContactPaymentContext? = null private val pendingContactPaymentContexts = mutableMapOf() - private val presentedPaymentRequestIds = mutableSetOf() + private var requestedPaymentRequestId: PaykitPaymentRequestId? = null private var isPresentingPaymentRequest = false + private var paymentRequestPresentationGeneration = 0L + private var activePaymentRequestPresentationGeneration: Long? = null + private var paymentRequestIdentity: String? = null + private var isPaymentRequestIdentityActivating = false private var isSubmittingPaymentRequest = false private var paykitPaymentRequestPollingJob: Job? = null private var initialPaykitPaymentRequestPollingJob: Job? = null @@ -571,40 +583,70 @@ class AppViewModel @Inject constructor( ) } .distinctUntilChanged() - .collect { state -> - if (!state.isPaykitEnabled || state.publicKey == null) { - lastPrivatePaykitContactKeys = emptySet() - paykitPaymentRequestRepo.clear() - return@collect - } + .collect(::synchronizePaykitContacts) + } + } - refreshPrivateOnlyPaykitReceiverMarker("contact sync") - if (!state.contactsLoaded) return@collect - - val removedKeys = lastPrivatePaykitContactKeys - state.contactKeys - removedKeys.forEach { - privatePaykitRepo.removeSavedContact(it) - .onFailure { error -> - Logger.warn( - "Failed to remove private Paykit contact '${PubkyPublicKeyFormat.redacted(it)}'", - error, - context = TAG, - ) - } + private suspend fun synchronizePaykitContacts(state: PaykitContactSyncState) { + if (!state.isPaykitEnabled || state.publicKey == null) { + isPaymentRequestIdentityActivating = true + lastPrivatePaykitContactKeys = emptySet() + invalidatePaymentRequestPresentation(dismissActiveRequest = paymentRequestIdentity != null) + clearPaymentRequestPresentationRetries() + paymentRequestIdentity = null + requestedPaymentRequestId = null + paymentRequestSheetTransitionJob?.cancel() + paymentRequestSheetTransitionJob = null + try { + paykitPaymentRequestRepo.clear() + } finally { + val remainsUnavailable = !isPaykitEnabled.value || pubkyRepo.publicKey.value == null + if (remainsUnavailable) isPaymentRequestIdentityActivating = false + } + return + } + + val identityChanged = !PubkyPublicKeyFormat.matches(paymentRequestIdentity, state.publicKey) + if (identityChanged) { + invalidatePaymentRequestPresentation(dismissActiveRequest = paymentRequestIdentity != null) + clearPaymentRequestPresentationRetries() + requestedPaymentRequestId = null + paymentRequestSheetTransitionJob?.cancel() + paymentRequestSheetTransitionJob = null + } + + isPaymentRequestIdentityActivating = true + try { + paykitPaymentRequestRepo.activate(state.publicKey) + if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return + paymentRequestIdentity = state.publicKey + refreshPrivateOnlyPaykitReceiverMarker("contact sync") + if (!state.contactsLoaded) return + + val removedKeys = lastPrivatePaykitContactKeys - state.contactKeys + removedKeys.forEach { + privatePaykitRepo.removeSavedContact(it) + .onFailure { error -> + Logger.warn( + "Failed to remove private Paykit contact '${PubkyPublicKeyFormat.redacted(it)}'", + error, + context = TAG, + ) } + } - privatePaykitRepo.prepareSavedContacts(state.contactKeys) - .onFailure { - Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) - } - privatePaykitRepo.pruneUnsavedContactState(state.contactKeys) - .onFailure { - Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) - } - privatePaykitRepo.startInitialLinkBurst(state.contactKeys, "contact sync") - refreshIncomingPaykitPaymentRequests() - lastPrivatePaykitContactKeys = state.contactKeys - } + privatePaykitRepo.prepareSavedContacts(state.contactKeys) + .onFailure { Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) } + privatePaykitRepo.pruneUnsavedContactState(state.contactKeys) + .onFailure { Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) } + privatePaykitRepo.startInitialLinkBurst(state.contactKeys, "contact sync") + if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) return + refreshIncomingPaykitPaymentRequests() + lastPrivatePaykitContactKeys = state.contactKeys + } finally { + if (PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, state.publicKey)) { + isPaymentRequestIdentityActivating = false + } } } @@ -645,7 +687,8 @@ class AppViewModel @Inject constructor( private suspend fun refreshIncomingPaykitPaymentRequests(): Boolean { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return false val previousRequests = paykitPaymentRequestRepo.pendingRequests.value - return paykitPaymentRequestRepo.refresh().fold( + val savedPublicKeys = pubkyRepo.contacts.value.map { it.publicKey } + return paykitPaymentRequestRepo.refresh(savedPublicKeys).fold( onSuccess = { presentNextIncomingPaykitPaymentRequest() paykitPaymentRequestRepo.pendingRequests.value != previousRequests @@ -677,9 +720,7 @@ class AppViewModel @Inject constructor( paykitPaymentRequestPollingJob = null initialPaykitPaymentRequestPollingJob?.cancel() initialPaykitPaymentRequestPollingJob = null - paymentRequestPresentationRetryJobs.values.forEach { it.cancel() } - paymentRequestPresentationRetryJobs.clear() - paymentRequestPresentationRetryAttempts.clear() + clearPaymentRequestPresentationRetries() } private fun startInitialPaykitPaymentRequestPolling() { @@ -696,12 +737,15 @@ class AppViewModel @Inject constructor( private fun observeIncomingPaykitPaymentRequests() { viewModelScope.launch { - currentSheet.collect { - if (it == null) presentNextIncomingPaykitPaymentRequest() + currentSheet.collect { sheet -> + if (sheet == null) { + presentNextIncomingPaykitPaymentRequest() + } } } viewModelScope.launch { paykitPaymentRequestRepo.pendingRequests.drop(1).collect { requests -> + retainPaymentRequestPresentationState(requests) val activeRequest = activeIncomingPaymentRequest() ?: return@collect if ( !isSubmittingPaymentRequest && @@ -714,31 +758,79 @@ class AppViewModel @Inject constructor( } } + fun onSheetVisible(sheet: Sheet?) { + if (sheet !is Sheet.Send || currentSheet.value !is Sheet.Send) return + val request = activeIncomingPaymentRequest() ?: return + viewModelScope.launch { + if (currentSheet.value !is Sheet.Send || activeIncomingPaymentRequest()?.id != request.id) return@launch + if (paykitPaymentRequestRepo.markPresented(request)) { + paymentRequestPresentationGeneration++ + requestedPaymentRequestId = null + clearPaymentRequestPresentationRetry(request.id) + } + } + } + private suspend fun presentNextIncomingPaykitPaymentRequest() { - val requests = paykitPaymentRequestRepo.pendingRequests.value - retainPaymentRequestPresentationState(requests) if (isPresentingPaymentRequest || isPaymentRequestPresentationBlocked()) return + val requests = paymentRequestsForPresentation() ?: return + val generation = paymentRequestPresentationGeneration isPresentingPaymentRequest = true + activePaymentRequestPresentationGeneration = generation + var stopped = false try { - for (request in requests.filter { request -> - val retryAttempts = paymentRequestPresentationRetryAttempts[request.id] ?: 0 - request.id !in presentedPaymentRequestIds && - retryAttempts <= PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS.size && - paymentRequestPresentationRetryJobs[request.id]?.isActive != true - }) { - if (presentIncomingPaymentRequestOrStop(request)) return + for (request in requests) { + if (presentIncomingPaymentRequestOrStop(request, generation)) { + stopped = true + break + } } } finally { isPresentingPaymentRequest = false + activePaymentRequestPresentationGeneration = null + } + + if ( + stopped && + generation != paymentRequestPresentationGeneration && + !isPaymentRequestPresentationBlocked() + ) { + presentNextIncomingPaykitPaymentRequest() + } + } + + private fun paymentRequestsForPresentation(): List? { + val requestedId = requestedPaymentRequestId + if (requestedId != null && paymentRequestPresentationRetryJobs[requestedId]?.isActive == true) return null + if (requestedId != null) { + val request = paykitPaymentRequestRepo.pendingRequest(requestedId) + if (request != null) return listOf(request) + invalidatePaymentRequestPresentation() + requestedPaymentRequestId = null + return null } + + return paykitPaymentRequestRepo.automaticPendingRequests().filter { request -> + val retryAttempts = paymentRequestPresentationRetryAttempts[request.id] ?: 0 + !paykitPaymentRequestRepo.isProcessing(request) && + retryAttempts <= PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS.size && + paymentRequestPresentationRetryJobs[request.id]?.isActive != true + }.takeIf { it.isNotEmpty() } } - private suspend fun presentIncomingPaymentRequestOrStop(request: PaykitPaymentRequest): Boolean { + private suspend fun presentIncomingPaymentRequestOrStop( + request: PaykitPaymentRequest, + generation: Long, + ): Boolean { val result = privatePaykitRepo.beginPaymentRequest(request).getOrNull() - if (isPaymentRequestPresentationBlocked()) return true - val isPending = paykitPaymentRequestRepo.isPending(request) - if (!isPending) { - presentedPaymentRequestIds += request.id + if (!isCurrentPaymentRequestPresentation(request, generation) || isPaymentRequestPresentationBlocked()) { + return true + } + if (!paykitPaymentRequestRepo.isPending(request)) { + if (requestedPaymentRequestId == request.id) { + invalidatePaymentRequestPresentation() + requestedPaymentRequestId = null + } return false } if (result !is PublicPaykitPaymentResult.Opened) { @@ -746,8 +838,6 @@ class AppViewModel @Inject constructor( return false } - clearPaymentRequestPresentationRetry(request.id) - presentedPaymentRequestIds += request.id openContactPayment( paymentRequest = result.paymentRequest, publicKey = request.counterparty, @@ -757,13 +847,31 @@ class AppViewModel @Inject constructor( return true } + private fun isCurrentPaymentRequestPresentation(request: PaykitPaymentRequest, generation: Long): Boolean = + activePaymentRequestPresentationGeneration == generation && + paymentRequestPresentationGeneration == generation && + !paykitPaymentRequestRepo.isProcessing(request) && + (requestedPaymentRequestId?.let { it == request.id } ?: true) + private fun deferPaymentRequestPresentation(request: PaykitPaymentRequest) { val attempt = paymentRequestPresentationRetryAttempts[request.id] ?: 0 paymentRequestPresentationRetryAttempts[request.id] = attempt + 1 val retryDelay = PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS.getOrNull(attempt) ?: run { Logger.warn("Giving up payment request presentation after '${attempt + 1}' attempts", context = TAG) + if (requestedPaymentRequestId == request.id) { + paymentRequestPresentationGeneration++ + requestedPaymentRequestId = null + showSheet(Sheet.PaymentRequests) + } return } + if (attempt == 0 && requestedPaymentRequestId == request.id) { + toast( + type = Toast.ToastType.INFO, + title = context.getString(R.string.wallet__payment_request), + description = context.getString(R.string.wallet__payment_request_waiting_for_details), + ) + } paymentRequestPresentationRetryJobs.remove(request.id)?.cancel() paymentRequestPresentationRetryJobs[request.id] = viewModelScope.launch { delay(retryDelay) @@ -774,11 +882,14 @@ class AppViewModel @Inject constructor( private fun retainPaymentRequestPresentationState(requests: List) { val requestIds = requests.mapTo(mutableSetOf()) { it.id } - presentedPaymentRequestIds.retainAll(requestIds) paymentRequestPresentationRetryAttempts.keys.retainAll(requestIds) paymentRequestPresentationRetryJobs.keys.filter { it !in requestIds }.forEach { paymentRequestPresentationRetryJobs.remove(it)?.cancel() } + if (requestedPaymentRequestId?.let { it !in requestIds } == true) { + invalidatePaymentRequestPresentation() + requestedPaymentRequestId = null + } } private fun clearPaymentRequestPresentationRetry(requestId: PaykitPaymentRequestId) { @@ -786,6 +897,32 @@ class AppViewModel @Inject constructor( paymentRequestPresentationRetryJobs.remove(requestId)?.cancel() } + private fun clearPaymentRequestPresentationRetries() { + paymentRequestPresentationRetryJobs.values.forEach { it.cancel() } + paymentRequestPresentationRetryJobs.clear() + paymentRequestPresentationRetryAttempts.clear() + } + + private fun invalidatePaymentRequestPresentation(dismissActiveRequest: Boolean = false) { + paymentRequestPresentationGeneration++ + scheduledScan + ?.takeIf { it.contactPaymentContext?.incomingPaymentRequest != null } + ?.job + ?.cancel() + synchronized(deferredScanLock) { + if (deferredScan?.contactPaymentContext?.incomingPaymentRequest != null) { + deferredScan = null + } + } + if (dismissActiveRequest && activeIncomingPaymentRequest() != null) { + if (currentSheet.value is Sheet.Send) { + hideSheet(shouldFlushDeferredScan = false) + } else { + clearActiveContactPaymentContext() + } + } + } + private suspend fun refreshPrivateOnlyPaykitReceiverMarker(reason: String) { val settings = settingsStore.data.first() if (!settings.sharesPrivatePaykitEndpoints || settings.sharesPublicPaykitEndpoints) return @@ -1752,9 +1889,11 @@ class AppViewModel @Inject constructor( return synchronized(deferredScanLock) { deferredScan != null } } - private fun isPaymentRequestPresentationBlocked() = !_isAuthenticated.value || + private fun isPaymentRequestPresentationBlocked() = isPaymentRequestIdentityActivating || + !_isAuthenticated.value || currentSheet.value != null || sheetTransitionJob?.isActive == true || + paymentRequestSheetTransitionJob?.isActive == true || hasActiveContactPaymentContext() || isScanPendingOrActive() @@ -2198,12 +2337,9 @@ class AppViewModel @Inject constructor( } private fun setActiveContactPaymentContext(context: ContactPaymentContext?) { - val replacedRequestId = synchronized(contactPaymentContextLock) { - val currentRequestId = activeContactPaymentContext?.incomingPaymentRequest?.id + synchronized(contactPaymentContextLock) { activeContactPaymentContext = context - currentRequestId?.takeIf { it != context?.incomingPaymentRequest?.id } } - if (replacedRequestId != null) presentedPaymentRequestIds -= replacedRequestId } private fun clearPendingContactPaymentContext(paymentHash: String) { @@ -2777,12 +2913,12 @@ class AppViewModel @Inject constructor( if (!validateIncomingPaymentRequest(contactPaymentContext)) return consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { - handlePaymentPreparationFailure(contactPaymentContext, it) + handlePaymentPreparationFailure(it) return } acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { - handlePaymentPreparationFailure(contactPaymentContext, it) + handlePaymentPreparationFailure(it) return } @@ -3434,6 +3570,13 @@ class AppViewModel @Inject constructor( fun hideSheet() = hideSheet(shouldFlushDeferredScan = true) private fun hideSheet(shouldFlushDeferredScan: Boolean) { + if ( + shouldFlushDeferredScan && + _currentSheet.value == null && + paymentRequestSheetTransitionJob?.isActive == true + ) { + return + } scanResultHandler = null receiveSheetContext = null sheetTransitionJob?.cancel() @@ -3672,13 +3815,64 @@ class AppViewModel @Inject constructor( return paykitPaymentRequestRepo.accept(request) } - private fun handlePaymentPreparationFailure(context: ContactPaymentContext?, error: Throwable) { - toast(error) - val request = context?.incomingPaymentRequest - if (request != null && paykitPaymentRequestRepo.isPending(request)) { - presentedPaymentRequestIds.remove(request.id) - deferPaymentRequestPresentation(request) + fun showPaymentRequests() { + showSheet(Sheet.PaymentRequests) + } + + fun openIncomingPaymentRequest(id: PaykitPaymentRequestId) { + val request = paykitPaymentRequestRepo.pendingRequest(id) ?: return + if (paykitPaymentRequestRepo.isProcessing(request) || requestedPaymentRequestId != null) return + invalidatePaymentRequestPresentation() + requestedPaymentRequestId = id + + if (_currentSheet.value is Sheet.PaymentRequests) { + hideSheet(shouldFlushDeferredScan = false) + paymentRequestSheetTransitionJob?.cancel() + val job = viewModelScope.launch { + delay(SCREEN_TRANSITION_DELAY) + paymentRequestSheetTransitionJob = null + presentNextIncomingPaykitPaymentRequest() + } + paymentRequestSheetTransitionJob = job + } else { + viewModelScope.launch { presentNextIncomingPaykitPaymentRequest() } + } + } + + suspend fun rejectIncomingPaymentRequest(request: PaykitPaymentRequest): Result { + if (requestedPaymentRequestId == request.id) { + return Result.failure(PaykitPaymentRequestError.OperationInProgress) + } + return paykitPaymentRequestRepo.reject(request).onFailure(::toast) + } + + suspend fun createPaymentRequest( + draft: PaykitPaymentRequestDraft, + target: PaykitPaymentRequestTarget, + ): Result = paykitPaymentRequestRepo.propose( + draft = draft, + target = target, + savedPublicKeys = pubkyRepo.contacts.value.map { it.publicKey }, + ) + + fun createPaymentRequest( + draft: PaykitPaymentRequestDraft, + target: PaykitPaymentRequestTarget, + onCreated: (PaykitPaymentRequest) -> Unit, + ) { + viewModelScope.launch { + createPaymentRequest(draft, target) + .onSuccess { request -> + if (sentPaymentRequests.value.any { it.id == request.id }) { + onCreated(request) + } + } + .onFailure(::toast) } + } + + private fun handlePaymentPreparationFailure(error: Throwable) { + toast(error) hideSheet() } @@ -3921,12 +4115,7 @@ class AppViewModel @Inject constructor( private const val PAYKIT_CHANNEL_USABILITY_REFRESH_DELAY_MS = 5_000L private val PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS = listOf(30.seconds, 60.seconds, 120.seconds) private val INITIAL_PAYKIT_SYNC_RETRY_DELAYS = List(14) { 2.seconds } - private val PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS = listOf( - 30.seconds, - 60.seconds, - 120.seconds, - 300.seconds, - ) + private val PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS = List(14) { 2.seconds } private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val BITKIT_SCHEME = "bitkit" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 357583ed8f..98d83ffa7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1169,6 +1169,34 @@ The recipient rejected this Lightning payment. Please check the invoice and try again. Payment Request The payment details did not match the request. Payment cancelled. + Payment Requests + Review requests you have received or sent. + INCOMING + SENT + No payment requests yet. + Not Now + See All + %1$d pending payment requests + Reject + Pay + Waiting for payment + Waiting for updated private payment details. Bitkit will retry automatically. + Queued for delivery + Send Payment Request + Amount + Note + What is this payment for? + Expires in + 1 hour + 1 day + 1 week + 1 month + Choose Recipient + Send Request + Sent + Requested]]> + You have sent a payment request + Your payment request is queued and will send automatically Bitkit tried several Lightning routes, but the payment could not be completed. Bitkit couldn\'t find a Lightning route for this payment. Payment timed out. Please try again. diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 47d388b48a..135505ebad 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -2,6 +2,9 @@ package to.bitkit.repositories +import com.synonym.paykit.IdentityStatus +import com.synonym.paykit.LinkedPeerRecord +import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaymentReference import com.synonym.paykit.PaymentRequestAmount import com.synonym.paykit.PaymentRequestLifecycleState @@ -9,19 +12,29 @@ import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import org.junit.After import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest @@ -38,7 +51,9 @@ import kotlin.time.Instant class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { companion object { private const val PAYMENT_REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000" - private const val COUNTERPARTY = "pubkypayee" + private const val COUNTERPARTY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val LOCAL_IDENTITY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val SECOND_IDENTITY = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private val START_TIME = Instant.parse("2027-01-15T08:00:00Z") private val PAYMENT_REFERENCE = mock { on { exportText() } doReturn "invoice-123" @@ -49,6 +64,8 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } private val paykitSdkService = mock() + private val settingsStore = mock() + private val presentationStore = mock() private var schedulerOriginMillis = 0L private val clock = object : Clock { override fun now(): Instant = START_TIME.plus( @@ -63,7 +80,12 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(emptyList()) - sut = PaykitPaymentRequestRepo(testDispatcher, paykitSdkService, clock) + whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) + whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) + whenever(presentationStore.load(LOCAL_IDENTITY)).thenReturn(emptySet()) + sut = PaykitPaymentRequestRepo(testDispatcher, paykitSdkService, settingsStore, presentationStore, clock) + sut.activate(LOCAL_IDENTITY) } @After @@ -76,7 +98,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord(expiresAt = clock.now().plus(60.seconds).toString()) whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) - sut.refresh().getOrThrow() + sut.refresh(emptyList()).getOrThrow() val request = sut.pendingRequests.value.single() assertEquals(100_000uL, request.amountSats) @@ -95,7 +117,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), ) - sut.refresh().getOrThrow() + sut.refresh(emptyList()).getOrThrow() assertEquals(listOf("millisatoshi-safe-max"), sut.pendingRequests.value.map { it.paymentRequestId }) assertEquals(listOf(ULong.MAX_VALUE / 1000uL), sut.pendingRequests.value.map { it.amountSats }) @@ -129,7 +151,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), ) - sut.refresh().getOrThrow() + sut.refresh(emptyList()).getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) } @@ -139,7 +161,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn( listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), ) - sut.refresh().getOrThrow() + sut.refresh(emptyList()).getOrThrow() advanceTimeBy(9_999) runCurrent() @@ -161,7 +183,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { PAYMENT_REQUEST_ID, ), ).thenReturn(record) - sut.refresh().getOrThrow() + sut.refresh(emptyList()).getOrThrow() clearInvocations(paykitSdkService) sut.accept(sut.pendingRequests.value.single()).getOrThrow() @@ -170,11 +192,183 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } } + @Test + fun `reject removes current request and delivers queued response`() = test { + val record = paymentRequestRecord() + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + whenever( + paykitSdkService.rejectPaymentRequest( + COUNTERPARTY, + PaykitReceiverPaths.SERVER, + PAYMENT_REQUEST_ID, + ), + ).thenReturn(record) + sut.refresh(emptyList()).getOrThrow() + clearInvocations(paykitSdkService) + + sut.reject(sut.pendingRequests.value.single()).getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } + } + + @Test + fun `surfaced request stays pending and is excluded from automatic presentation`() = test { + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(paymentRequestRecord())) + sut.refresh(emptyList()).getOrThrow() + val request = sut.pendingRequests.value.single() + + assertTrue(sut.markPresented(request)) + + assertEquals(listOf(request), sut.pendingRequests.value) + assertTrue(sut.automaticPendingRequests().isEmpty()) + verifyBlocking(presentationStore) { save(LOCAL_IDENTITY, setOf(request.id)) } + } + + @Test + fun `switching identity clears request state and restores only that identity suppression`() = test { + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(paymentRequestRecord())) + sut.refresh(emptyList()).getOrThrow() + val request = sut.pendingRequests.value.single() + sut.markPresented(request) + whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(setOf(request.id)) + + sut.activate(SECOND_IDENTITY) + + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.sentRequests.value.isEmpty()) + assertTrue(sut.eligibleTargets.value.isEmpty()) + } + + @Test + fun `identity switch invalidates an in-flight refresh before waiting for the operation lock`() = test { + val refreshStarted = CompletableDeferred() + val resumeRefresh = CompletableDeferred() + whenever(paykitSdkService.processPendingPrivateMessages()).doSuspendableAnswer { + refreshStarted.complete(Unit) + resumeRefresh.await() + emptyList() + } + whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(emptySet()) + + val refresh = async { sut.refresh(emptyList()) } + runCurrent() + refreshStarted.await() + val activation = async { sut.activate(SECOND_IDENTITY) } + runCurrent() + resumeRefresh.complete(Unit) + + refresh.await() + activation.await() + + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.sentRequests.value.isEmpty()) + assertTrue(sut.eligibleTargets.value.isEmpty()) + } + + @Test + fun `proposal uses exact linked capable path and canonical bitcoin terms`() = 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( + eq(COUNTERPARTY), + eq(PaykitReceiverPaths.SERVER), + any(), + eq(LOCAL_IDENTITY), + ), + ).thenReturn( + paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + counterparty = COUNTERPARTY, + receiverPath = PaykitReceiverPaths.SERVER, + ), + ) + val expiry = clock.now().plus(60.seconds) + + val request = sut.propose( + draft = PaykitPaymentRequestDraft(amountSats = 1uL, note = " Lunch ", expiresAt = expiry), + target = target, + savedPublicKeys = listOf(COUNTERPARTY), + ).getOrThrow() + + val proposal = argumentCaptor() + verifyBlocking(paykitSdkService) { + proposePaymentRequest( + eq(COUNTERPARTY), + eq(PaykitReceiverPaths.SERVER), + proposal.capture(), + eq(LOCAL_IDENTITY), + ) + } + assertEquals("0.00000001", proposal.firstValue.amountValue) + assertTrue(proposal.firstValue.paymentReference.startsWith("bitkit-")) + assertEquals(expiry.toString(), proposal.firstValue.proposalExpiresAt) + assertTrue(proposal.firstValue.acceptedPaymentEndpointIdentifiers.isNotEmpty()) + assertEquals("{\"note\":\"Lunch\"}", proposal.firstValue.metadataJson) + assertEquals("Lunch", request.note) + assertEquals(PaykitPaymentRequestDeliveryStatus.Queued, request.deliveryStatus) + } + + @Test + fun `outgoing requests require private payment publication`() = test { + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = false))) + 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), + ) + + sut.refresh(listOf(COUNTERPARTY)).getOrThrow() + + assertTrue(sut.eligibleTargets.value.isEmpty()) + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + } + + @Test + fun `outgoing requests require the active SDK identity`() = test { + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(SECOND_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + listOf(PaykitReceiverPaths.SERVER), + ) + + sut.refresh(listOf(COUNTERPARTY)).getOrThrow() + + assertTrue(sut.eligibleTargets.value.isEmpty()) + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + } + + @Test + fun `expired draft is rejected before proposal is queued`() = test { + val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + + assertFailsWith { + sut.propose( + draft = PaykitPaymentRequestDraft(1uL, "", clock.now()), + target = target, + savedPublicKeys = listOf(COUNTERPARTY), + ).getOrThrow() + } + verifyBlocking(paykitSdkService, never()) { proposePaymentRequest(any(), any(), any(), any()) } + } + @Test fun `expired request cannot be accepted`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) - sut.refresh().getOrThrow() + sut.refresh(emptyList()).getOrThrow() val request = sut.pendingRequests.value.single() advanceTimeBy(1_000) @@ -190,7 +384,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { fun `expired request is no longer pending before the expiration job runs`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) - sut.refresh().getOrThrow() + sut.refresh(emptyList()).getOrThrow() val request = sut.pendingRequests.value.single() advanceTimeBy(1_000) @@ -204,9 +398,11 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { amount: String = "0.001", expiresAt: String? = null, endpoints: List = listOf(MethodId.Bolt11.rawValue), + counterparty: String = COUNTERPARTY, + receiverPath: String = PaykitReceiverPaths.SERVER, ) = PaymentRequestRecord( - counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + counterparty = counterparty, + counterpartyReceiverPath = receiverPath, paymentRequestId = id, localRole = role, state = PaymentRequestLifecycleState.PROPOSED, @@ -235,4 +431,22 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { lastEventAt = clock.now().toString(), invalidReason = null, ) + + private fun linkedPeer( + publicKey: String, + state: LinkedPeerState, + receiverPath: String, + ) = LinkedPeerRecord( + counterparty = publicKey, + counterpartyReceiverPath = receiverPath, + state = state, + lastSyncAt = null, + lastPrivateReceiveAt = null, + failureCount = 0u, + localRecoveryAttemptId = null, + localRecoveryMarkerCreatedAt = null, + localRecoveryMarkerLastError = null, + remoteRecoveryAttemptId = null, + remoteRecoveryMarkerObservedAt = null, + ) } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index c68f688c92..88cfcfe2af 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -144,6 +144,27 @@ class PaykitSdkServiceTest { assertNull(provider.loadLocalSecretKey()) } + @Test + fun `stale session can be deferred until sdk initialization completes`() { + val keychain = mock() + val provider = PaykitSdkSessionProvider(keychain) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved-session") + + assertTrue(provider.canDeferStaleSession("import Pubky session from platform provider")) + provider.suspendStoredSessionAccess() + assertNull(provider.loadSessionAccess()) + } + + @Test + fun `missing session or unrelated identity failures are not deferred`() { + val keychain = mock() + val provider = PaykitSdkSessionProvider(keychain) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(null) + + assertTrue(!provider.canDeferStaleSession("import Pubky session from platform provider")) + assertTrue(!provider.canDeferStaleSession("local Pubky secret key does not match session public key")) + } + private fun keyStore( loadBytes: () -> ByteArray?, upsertBytes: (ByteArray) -> Unit = {}, diff --git a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestExpirationTest.kt b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestExpirationTest.kt new file mode 100644 index 0000000000..b29e68f8c8 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestExpirationTest.kt @@ -0,0 +1,26 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.paymentrequests + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class PaymentRequestExpirationTest { + private val now = Instant.parse("2027-01-15T08:00:00Z") + + @Test + fun `expired draft uses the default expiration`() { + assertEquals(PaymentRequestExpiration.Week, PaymentRequestExpiration.from(now, now)) + } + + @Test + fun `retained draft restores its closest expiration`() { + assertEquals(PaymentRequestExpiration.Hour, PaymentRequestExpiration.from(now + 1.hours, now)) + assertEquals(PaymentRequestExpiration.Day, PaymentRequestExpiration.from(now + 1.days, now)) + assertEquals(PaymentRequestExpiration.Month, PaymentRequestExpiration.from(now + 30.days, now)) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index e58a089b26..19c728027d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -85,7 +85,10 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDraft +import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo +import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution @@ -127,7 +130,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.minutes +import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @@ -185,6 +188,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val pubkyContacts = MutableStateFlow>(emptyList()) private val pubkyContactsLoadVersion = MutableStateFlow(0L) private val pendingPaykitPaymentRequests = MutableStateFlow>(emptyList()) + private val sentPaykitPaymentRequests = MutableStateFlow>(emptyList()) + private val surfacedPaykitPaymentRequestIds = mutableSetOf() private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private val timedSheetManager = mock() @@ -193,6 +198,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Before fun setUp() { timedSheetType.value = null + sentPaykitPaymentRequests.value = emptyList() + surfacedPaykitPaymentRequestIds.clear() stubRepositories() sut = createViewModel() } @@ -241,7 +248,22 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) + whenever(paykitPaymentRequestRepo.sentRequests).thenReturn(sentPaykitPaymentRequests) + whenever(paykitPaymentRequestRepo.eligibleTargets).thenReturn(MutableStateFlow(emptyList())) + whenever(paykitPaymentRequestRepo.isCreatingRequest).thenReturn(MutableStateFlow(false)) + whenever(paykitPaymentRequestRepo.automaticPendingRequests()).thenAnswer { + pendingPaykitPaymentRequests.value.filterNot { it.id in surfacedPaykitPaymentRequestIds } + } + whenever(paykitPaymentRequestRepo.pendingRequest(any())).thenAnswer { + val id = it.getArgument(0) + pendingPaykitPaymentRequests.value.firstOrNull { request -> request.id == id } + } + whenever { paykitPaymentRequestRepo.markPresented(any()) }.thenAnswer { + surfacedPaykitPaymentRequestIds += it.getArgument(0).id + true + } whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) + whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(privatePaykitRepo.initialLinkBurstStarted).thenReturn(MutableSharedFlow()) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) @@ -438,7 +460,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).beginPaymentRequest(request) clearInvocations(privatePaykitRepo) - advanceTimeBy(29.seconds.inWholeMilliseconds) + advanceTimeBy(1.seconds.inWholeMilliseconds) runCurrent() verify(privatePaykitRepo, never()).beginPaymentRequest(request) @@ -449,6 +471,179 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `manually reopened request waits for a newer private list and then opens`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val bolt11 = "lnbcrt1updatedmanualrequest" + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 8uL) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = privateContext, + ), + ), + ) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request) + surfacedPaykitPaymentRequestIds += request.id + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo).beginPaymentRequest(request) + + advanceTimeBy(2.seconds.inWholeMilliseconds) + runCurrent() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + } + + @Test + fun `latest identity activation blocks retries from the previous payment request queue`() = test { + val request = paymentRequest() + val nextIdentity = "pubky8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo" + val latestIdentity = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val activationStarted = CompletableDeferred() + val finishActivation = CompletableDeferred() + val latestActivationStarted = CompletableDeferred() + val finishLatestActivation = CompletableDeferred() + sut.setIsAuthenticated(true) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + ) + whenever(paykitPaymentRequestRepo.activate(nextIdentity)).doSuspendableAnswer { + activationStarted.complete(Unit) + finishActivation.await() + } + whenever(paykitPaymentRequestRepo.activate(latestIdentity)).doSuspendableAnswer { + latestActivationStarted.complete(Unit) + finishLatestActivation.await() + pendingPaykitPaymentRequests.value = emptyList() + } + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + sut.onHomeResumed() + runCurrent() + verify(privatePaykitRepo).beginPaymentRequest(request) + + pubkyPublicKey.value = nextIdentity + activationStarted.await() + pubkyPublicKey.value = latestIdentity + advanceTimeBy(2.seconds.inWholeMilliseconds) + runCurrent() + + verify(privatePaykitRepo).beginPaymentRequest(request) + + finishActivation.complete(Unit) + latestActivationStarted.await() + advanceTimeBy(2.seconds.inWholeMilliseconds) + runCurrent() + + verify(privatePaykitRepo).beginPaymentRequest(request) + + finishLatestActivation.complete(Unit) + advanceUntilIdle() + + verify(privatePaykitRepo).beginPaymentRequest(request) + assertNull(sut.currentSheet.value) + assertNull(activeContactPaymentContext()) + } + + @Test + fun `identity clear blocks retries from the previous payment request queue`() = test { + val request = paymentRequest() + val clearStarted = CompletableDeferred() + val finishClear = CompletableDeferred() + runCurrent() + sut.setIsAuthenticated(true) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + sut.onHomeResumed() + runCurrent() + verify(privatePaykitRepo).beginPaymentRequest(request) + + whenever(paykitPaymentRequestRepo.clear()).doSuspendableAnswer { + clearStarted.complete(Unit) + finishClear.await() + pendingPaykitPaymentRequests.value = emptyList() + } + pubkyPublicKey.value = null + clearStarted.await() + advanceTimeBy(2.seconds.inWholeMilliseconds) + runCurrent() + + verify(privatePaykitRepo).beginPaymentRequest(request) + + finishClear.complete(Unit) + advanceUntilIdle() + + verify(privatePaykitRepo).beginPaymentRequest(request) + assertNull(sut.currentSheet.value) + assertNull(activeContactPaymentContext()) + } + + @Test + fun `manual request selection supersedes in-flight automatic presentation`() = test { + sut.setIsAuthenticated(true) + val automaticRequest = paymentRequest() + val manualRequest = automaticRequest.copy(paymentRequestId = "manual-request") + val automaticResolutionStarted = CompletableDeferred() + val resumeAutomaticResolution = CompletableDeferred() + val automaticInvoice = "lnbcrt1staleautomaticrequest" + val manualInvoice = "lnbcrt1manualrequest" + whenever(privatePaykitRepo.beginPaymentRequest(automaticRequest)).doSuspendableAnswer { + automaticResolutionStarted.complete(Unit) + resumeAutomaticResolution.await() + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = automaticInvoice, + privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 7uL), + ), + ) + } + stubOpenedPaymentRequest(manualRequest, manualInvoice, privateListIndex = 8uL) + stubLightningScan(bolt11 = manualInvoice, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(automaticRequest, manualRequest) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + + sut.onHomeResumed() + automaticResolutionStarted.await() + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(manualRequest.id) + resumeAutomaticResolution.complete(Unit) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(manualRequest, activeContactPaymentContext()?.incomingPaymentRequest) + verify(coreService, never()).decode(automaticInvoice) + verify(privatePaykitRepo).beginPaymentRequest(manualRequest) + } + @Test fun `unresolvable payment request retries are bounded`() = test { val request = paymentRequest() @@ -461,11 +656,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() sut.startPaykitPaymentRequestPolling() - advanceTimeBy(20.minutes.inWholeMilliseconds) + advanceTimeBy(30.seconds.inWholeMilliseconds) runCurrent() sut.stopPaykitPaymentRequestPolling() - verify(privatePaykitRepo, times(5)).beginPaymentRequest(request) + verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) } @Test @@ -509,6 +704,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.currentSheet.first { it is Sheet.Send && activeContactPaymentContext()?.incomingPaymentRequest == firstRequest } + sut.onSheetVisible(sut.currentSheet.value) + runCurrent() sut.setIsAuthenticated(false) sut.hideSheet() @@ -2540,6 +2737,39 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ContactPaymentContext(testPublicKey, privateContext, request), activeContactPaymentContext(), ) + assertFalse(request.id in surfacedPaykitPaymentRequestIds) + + sut.onSheetVisible(sut.currentSheet.value) + runCurrent() + + assertTrue(request.id in surfacedPaykitPaymentRequestIds) + } + + @Test + fun `outgoing payment request creation continues after its caller returns`() = test { + val request = paymentRequest().copy(counterparty = "pubkyrecipient") + val target = PaykitPaymentRequestTarget(request.counterparty, request.counterpartyReceiverPath) + val draft = PaykitPaymentRequestDraft( + amountSats = request.amountSats, + note = "Lunch", + expiresAt = Clock.System.now() + 60.seconds, + ) + val creationStarted = CompletableDeferred() + val finishCreation = CompletableDeferred() + val callbackRequest = CompletableDeferred() + whenever(paykitPaymentRequestRepo.propose(draft, target, emptyList())).doSuspendableAnswer { + creationStarted.complete(Unit) + finishCreation.await() + sentPaykitPaymentRequests.value = listOf(request) + Result.success(request) + } + + sut.createPaymentRequest(draft, target) { callbackRequest.complete(it) } + creationStarted.await() + finishCreation.complete(Unit) + runCurrent() + + assertEquals(request, callbackRequest.await()) } @Test diff --git a/changelog.d/next/payment-requests.added.md b/changelog.d/next/payment-requests.added.md new file mode 100644 index 0000000000..c95957385d --- /dev/null +++ b/changelog.d/next/payment-requests.added.md @@ -0,0 +1 @@ +Added private Paykit payment requests with automatic incoming prompts, a persistent request queue, and contact-based request creation. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 41012a0489..ad9ccae8d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.5" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc43" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc44" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From e20355863573275abe039b81f888aec6480ee12f Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 20 Aug 2026 04:23:01 -0500 Subject: [PATCH 2/8] chore: rename changelog fragment --- changelog.d/next/{payment-requests.added.md => 1172.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{payment-requests.added.md => 1172.added.md} (100%) diff --git a/changelog.d/next/payment-requests.added.md b/changelog.d/next/1172.added.md similarity index 100% rename from changelog.d/next/payment-requests.added.md rename to changelog.d/next/1172.added.md From 532ef85c9c15bc20af3ab3be06236996bee708b2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 20 Aug 2026 05:02:45 -0500 Subject: [PATCH 3/8] fix: handle committed request identity changes --- .../repositories/PaykitPaymentRequestRepo.kt | 31 ++++++-- .../to/bitkit/services/PaykitSdkService.kt | 6 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 24 ++++-- .../PaykitPaymentRequestRepoTest.kt | 44 ++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 79 ++++++++++++++++++- 5 files changed, 167 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 18352320d4..1bccf9cb8b 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -98,6 +98,12 @@ data class PaykitPaymentRequestDraft( val expiresAt: Instant, ) +data class PaykitPaymentRequestCreation( + val request: PaykitPaymentRequest, + val creatorIdentity: String, + val wasPublishedToActiveState: Boolean, +) + sealed class PaykitPaymentRequestError(message: String) : AppError(message) { data object RequestUnavailable : PaykitPaymentRequestError("Payment request is unavailable") data object RequestExpired : PaykitPaymentRequestError("Payment request has expired") @@ -193,7 +199,7 @@ class PaykitPaymentRequestRepo @Inject constructor( draft: PaykitPaymentRequestDraft, target: PaykitPaymentRequestTarget, savedPublicKeys: List, - ): Result = withContext(ioDispatcher) { + ): Result = withContext(ioDispatcher) { runSuspendCatching { if (!creationMutex.tryLock()) throw PaykitPaymentRequestError.OperationInProgress _isCreatingRequest.update { true } @@ -235,13 +241,7 @@ class PaykitPaymentRequestRepo @Inject constructor( createdAt = proposalDate, reports = reports, ) - if (isCurrentState(generation, expectedIdentity)) { - _sentRequests.update { requests -> - listOf(request) + requests.filterNot { it.id == request.id } - } - scheduleExpirationLocked() - } - request + publishCreatedRequest(request, generation, expectedIdentity) } } finally { _isCreatingRequest.update { false } @@ -252,6 +252,21 @@ class PaykitPaymentRequestRepo @Inject constructor( } } + private fun publishCreatedRequest( + request: PaykitPaymentRequest, + generation: Long, + creatorIdentity: String, + ): PaykitPaymentRequestCreation { + val wasPublishedToActiveState = isCurrentState(generation, creatorIdentity) + if (wasPublishedToActiveState) { + _sentRequests.update { requests -> + listOf(request) + requests.filterNot { it.id == request.id } + } + scheduleExpirationLocked() + } + return PaykitPaymentRequestCreation(request, creatorIdentity, wasPublishedToActiveState) + } + suspend fun accept(request: PaykitPaymentRequest): Result = updateRequest(request) { paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index e642d2a5ae..18e8ad4fc6 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -174,7 +174,11 @@ class PaykitSdkService @Inject constructor( } catch (e: PaykitException.Identity) { if (!sessionProvider.canDeferStaleSession(e.context)) throw e - Logger.warn("Deferring stale Paykit session restoration until SDK setup completes", e, context = TAG) + Logger.warn( + "Deferring stale Paykit session restoration until SDK setup completes", + e, + context = TAG, + ) sessionProvider.suspendStoredSessionAccess() resetRuntime() try { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b2742e06f1..b381e4b70e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -143,6 +143,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestCreation import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId @@ -3846,10 +3847,10 @@ class AppViewModel @Inject constructor( return paykitPaymentRequestRepo.reject(request).onFailure(::toast) } - suspend fun createPaymentRequest( + private suspend fun createPaymentRequest( draft: PaykitPaymentRequestDraft, target: PaykitPaymentRequestTarget, - ): Result = paykitPaymentRequestRepo.propose( + ): Result = paykitPaymentRequestRepo.propose( draft = draft, target = target, savedPublicKeys = pubkyRepo.contacts.value.map { it.publicKey }, @@ -3860,11 +3861,24 @@ class AppViewModel @Inject constructor( target: PaykitPaymentRequestTarget, onCreated: (PaykitPaymentRequest) -> Unit, ) { + val sourceReceiveSheet = currentSheet.value as? Sheet.Receive viewModelScope.launch { createPaymentRequest(draft, target) - .onSuccess { request -> - if (sentPaymentRequests.value.any { it.id == request.id }) { - onCreated(request) + .onSuccess { creation -> + val creatorIsCurrent = PubkyPublicKeyFormat.matches( + creation.creatorIdentity, + pubkyRepo.publicKey.value, + ) + if (creation.wasPublishedToActiveState && creatorIsCurrent) { + onCreated(creation.request) + } else { + if (sourceReceiveSheet != null && currentSheet.value === sourceReceiveSheet) hideSheet() + toast( + type = Toast.ToastType.INFO, + title = context.getString(R.string.wallet__payment_request), + description = context.getString(R.string.wallet__payment_request_queued_description), + testTag = "PaymentRequestQueuedToast", + ) } } .onFailure(::toast) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 135505ebad..6a10dd2658 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -293,11 +293,12 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) val expiry = clock.now().plus(60.seconds) - val request = sut.propose( + val creation = sut.propose( draft = PaykitPaymentRequestDraft(amountSats = 1uL, note = " Lunch ", expiresAt = expiry), target = target, savedPublicKeys = listOf(COUNTERPARTY), ).getOrThrow() + val request = creation.request val proposal = argumentCaptor() verifyBlocking(paykitSdkService) { @@ -315,6 +316,47 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals("{\"note\":\"Lunch\"}", proposal.firstValue.metadataJson) assertEquals("Lunch", request.note) assertEquals(PaykitPaymentRequestDeliveryStatus.Queued, request.deliveryStatus) + assertEquals(LOCAL_IDENTITY, creation.creatorIdentity) + assertTrue(creation.wasPublishedToActiveState) + } + + @Test + fun `identity switch keeps a committed proposal out of the replacement identity state`() = test { + val target = PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER) + val proposalStarted = CompletableDeferred() + val finishProposal = CompletableDeferred() + 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))).doSuspendableAnswer { + proposalStarted.complete(Unit) + finishProposal.await() + paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE) + } + whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(emptySet()) + + val proposal = async { + sut.propose( + draft = PaykitPaymentRequestDraft(1uL, "Lunch", clock.now().plus(60.seconds)), + target = target, + savedPublicKeys = listOf(COUNTERPARTY), + ).getOrThrow() + } + proposalStarted.await() + val activation = async { sut.activate(SECOND_IDENTITY) } + runCurrent() + finishProposal.complete(Unit) + + val creation = proposal.await() + activation.await() + + assertEquals(LOCAL_IDENTITY, creation.creatorIdentity) + assertFalse(creation.wasPublishedToActiveState) + assertTrue(sut.sentRequests.value.isEmpty()) } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 19c728027d..e998a49b97 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -85,6 +85,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestCreation import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo @@ -2757,11 +2758,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val creationStarted = CompletableDeferred() val finishCreation = CompletableDeferred() val callbackRequest = CompletableDeferred() + pubkyPublicKey.value = testPublicKey whenever(paykitPaymentRequestRepo.propose(draft, target, emptyList())).doSuspendableAnswer { creationStarted.complete(Unit) finishCreation.await() - sentPaykitPaymentRequests.value = listOf(request) - Result.success(request) + Result.success(paymentRequestCreation(request)) } sut.createPaymentRequest(draft, target) { callbackRequest.complete(it) } @@ -2772,6 +2773,71 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(request, callbackRequest.await()) } + @Test + fun `committed outgoing request closes inactive identity flow and shows queued feedback`() = test { + val request = paymentRequest().copy(counterparty = "pubkyrecipient") + val target = PaykitPaymentRequestTarget(request.counterparty, request.counterpartyReceiverPath) + val draft = PaykitPaymentRequestDraft( + amountSats = request.amountSats, + note = "Lunch", + expiresAt = Clock.System.now() + 60.seconds, + ) + val callbackRequest = CompletableDeferred() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.propose(draft, target, emptyList())).thenReturn( + Result.success(paymentRequestCreation(request, wasPublishedToActiveState = false)) + ) + sut.showSheet(Sheet.Receive()) + runCurrent() + + sut.createPaymentRequest(draft, target) { callbackRequest.complete(it) } + runCurrent() + + assertFalse(callbackRequest.isCompleted) + assertNull(sut.currentSheet.value) + verify(toastManager).enqueue( + check { + assertEquals("PaymentRequestQueuedToast", it.testTag) + } + ) + } + + @Test + fun `inactive identity completion preserves a replacement sheet`() = test { + val request = paymentRequest().copy(counterparty = "pubkyrecipient") + val target = PaykitPaymentRequestTarget(request.counterparty, request.counterpartyReceiverPath) + val draft = PaykitPaymentRequestDraft( + amountSats = request.amountSats, + note = "Lunch", + expiresAt = Clock.System.now() + 60.seconds, + ) + val creationStarted = CompletableDeferred() + val finishCreation = CompletableDeferred() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.propose(draft, target, emptyList())).doSuspendableAnswer { + creationStarted.complete(Unit) + finishCreation.await() + Result.success(paymentRequestCreation(request, wasPublishedToActiveState = false)) + } + sut.showSheet(Sheet.Receive()) + runCurrent() + + sut.createPaymentRequest(draft, target) {} + creationStarted.await() + sut.showSheet(Sheet.PaymentRequests) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + finishCreation.complete(Unit) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(toastManager).enqueue( + check { + assertEquals("PaymentRequestQueuedToast", it.testTag) + } + ) + } + @Test fun `incoming payment request closes when it is no longer pending`() = test { val request = paymentRequest() @@ -3645,6 +3711,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { expiresAt = null, acceptedPaymentEndpointIdentifiers = listOf("lightning_bolt11"), ) + + private fun paymentRequestCreation( + request: PaykitPaymentRequest, + wasPublishedToActiveState: Boolean = true, + ) = PaykitPaymentRequestCreation( + request = request, + creatorIdentity = testPublicKey, + wasPublishedToActiveState = wasPublishedToActiveState, + ) } private const val SAMROCK_SETUP_URL = From 45346164e4a211acadfad6e0f2b794e5907b0a53 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 20 Aug 2026 13:54:15 -0500 Subject: [PATCH 4/8] feat: add payment request flows --- .../to/bitkit/ui/components/SheetHostTest.kt | 2 +- .../CreatePaymentRequestScreenTest.kt | 119 +++++ .../PaymentRequestsScreenTest.kt | 95 ++++ .../wallets/receive/EditInvoiceScreenTest.kt | 68 +++ .../repositories/PaykitPaymentRequestRepo.kt | 79 +++- .../to/bitkit/services/PaykitSdkService.kt | 7 - app/src/main/java/to/bitkit/ui/ContentView.kt | 3 + .../bitkit/ui/components/PubkyContactRow.kt | 81 ++++ .../CreatePaymentRequestScreen.kt | 431 ++++++++++++------ .../paymentrequests/PaymentRequestsScreen.kt | 366 ++++++++++++--- .../bitkit/ui/screens/wallets/HomeScreen.kt | 55 +-- .../wallets/receive/EditInvoiceScreen.kt | 18 + .../wallets/receive/ReceiveQrScreen.kt | 13 - .../screens/wallets/receive/ReceiveSheet.kt | 23 +- .../wallets/send/SendContactSelectScreen.kt | 45 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 61 ++- .../PaykitPaymentRequestRepoTest.kt | 97 +++- .../viewmodels/AppViewModelSendFlowTest.kt | 6 +- changelog.d/next/1172.added.md | 2 +- gradle/libs.versions.toml | 2 +- 21 files changed, 1192 insertions(+), 383 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreenTest.kt create mode 100644 app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt index c5a73d6b50..c0be748197 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt @@ -19,12 +19,12 @@ import androidx.compose.ui.unit.dp import androidx.test.espresso.Espresso.pressBack import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import to.bitkit.test.annotations.ComposeUi import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertEquals @HiltAndroidTest @ComposeUi diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt new file mode 100644 index 0000000000..7c0e948049 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt @@ -0,0 +1,119 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.paymentrequests + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.AmountInputHandler +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestDraft +import to.bitkit.repositories.PaykitPaymentRequestTarget +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.viewmodels.AmountInputViewModel +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@ComposeUi +class CreatePaymentRequestScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun detailsShowsAmountNoteExpiryAndContinue() { + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestDetailsContent( + amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), + initialDraft = draft, + onBack = {}, + onContinue = {}, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestAmountField").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestNote").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestExpiryWeek").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestAmountContinue").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestNumberPad").assertDoesNotExist() + + composeTestRule.onNodeWithTag("PaymentRequestEditAmount").performClick() + + composeTestRule.onNodeWithTag("PaymentRequestNumberPad").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestNote").assertDoesNotExist() + } + + @Test + fun recipientShowsEligibleContactAndSendAction() { + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestRecipientContent( + targets = persistentListOf(target), + contacts = persistentListOf(PubkyProfile.placeholder(target.publicKey)), + isCreating = false, + onBack = {}, + onSend = {}, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestContact${target.publicKey}").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestRecipientSearch").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestSend").assertIsDisplayed() + + composeTestRule.onNodeWithTag("PaymentRequestRecipientSearch").performTextInput("not this contact") + + composeTestRule.onNodeWithTag("PaymentRequestContact${target.publicKey}").assertDoesNotExist() + } + + @Test + fun sentShowsSuccessSurface() { + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestSentContent( + request = request.copy(deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent), + contact = PubkyProfile.placeholder(target.publicKey), + onDone = {}, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestSent").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestSentCheck").assertIsDisplayed() + composeTestRule.onNodeWithText("PAYMENT REQUESTED").assertIsDisplayed() + composeTestRule.onNodeWithText("Waiting for payment").assertIsDisplayed() + } + + private val draft = PaykitPaymentRequestDraft( + amountSats = 25_000uL, + note = "Dinner", + expiresAt = Instant.parse("2027-01-15T09:00:00Z"), + ) + + private val target = PaykitPaymentRequestTarget( + publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", + receiverPath = "bitkit/wallet", + ) + + private val request = PaykitPaymentRequest( + paymentRequestId = "payment-request", + counterparty = target.publicKey, + counterpartyReceiverPath = target.receiverPath, + amountValue = "0.00025", + amountSats = draft.amountSats, + note = draft.note, + createdAt = Instant.parse("2027-01-15T08:00:00Z"), + expiresAt = draft.expiresAt, + acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), + ) +} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt new file mode 100644 index 0000000000..b5bacf7150 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -0,0 +1,95 @@ +@file:OptIn(ExperimentalTime::class) + +package to.bitkit.ui.screens.paymentrequests + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import com.synonym.paykit.PaymentRequestLifecycleState +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestDirection +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@ComposeUi +class PaymentRequestsScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun queueShowsIncomingRequestAndSeeAllAction() { + val request = request(id = "incoming") + + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestsSheetContent( + requests = persistentListOf(request), + contacts = persistentListOf(), + onNotNow = {}, + onSeeAll = {}, + onPay = {}, + onReject = { Result.success(Unit) }, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestRowincoming").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestsSeeAll").assertIsDisplayed() + composeTestRule.onNodeWithText("Dismiss").assertIsDisplayed() + } + + @Test + fun historyGroupsCompletedRequestsAndKeepsActiveOutgoingRequests() { + val accepted = request(id = "accepted").copy( + createdAt = Clock.System.now(), + lifecycleState = PaymentRequestLifecycleState.ACCEPTED, + ) + val outgoing = request(id = "outgoing").copy( + createdAt = Clock.System.now(), + direction = PaykitPaymentRequestDirection.Outgoing, + deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent, + ) + + composeTestRule.setContent { + AppThemeSurface { + PaymentRequestsContent( + requests = persistentListOf(outgoing, accepted), + pending = persistentListOf(), + contacts = persistentListOf(), + canRequestPayment = true, + onBack = {}, + onRequestPayment = {}, + onPay = {}, + onReject = { Result.success(Unit) }, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestRowaccepted").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestRowoutgoing").assertIsDisplayed() + composeTestRule.onNodeWithText("Waiting for", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("PAYMENT REQUESTS").assertIsDisplayed() + composeTestRule.onNodeWithText("THIS MONTH").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestCreate").assertIsDisplayed() + } + + private fun request(id: String) = PaykitPaymentRequest( + paymentRequestId = id, + counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", + counterpartyReceiverPath = "bitkit/wallet", + amountValue = "0.00025", + amountSats = 25_000uL, + note = "Dinner", + createdAt = Instant.parse("2027-01-15T08:00:00Z"), + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), + ) +} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreenTest.kt new file mode 100644 index 0000000000..168e6b14ad --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreenTest.kt @@ -0,0 +1,68 @@ +package to.bitkit.ui.screens.wallets.receive + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import to.bitkit.repositories.AmountInputHandler +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.viewmodels.AmountInputViewModel + +@ComposeUi +class EditInvoiceScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun paymentRequestActionAndShowQrAreVisibleWhenEligible() { + composeTestRule.setContent { + AppThemeSurface { + EditInvoiceContent( + amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), + noteText = "Dinner", + isSoftKeyboardVisible = false, + keyboardVisible = false, + tags = persistentListOf(), + onBack = {}, + onContinueKeyboard = {}, + onClickBalance = {}, + onContinueGeneral = {}, + onClickAddTag = {}, + onTextChanged = {}, + onClickTag = {}, + showPaymentRequestButton = true, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestSendButton").assertIsDisplayed() + composeTestRule.onNodeWithTag("ShowQrReceive").assertIsDisplayed() + } + + @Test + fun paymentRequestActionIsHiddenWithoutEligibleContact() { + composeTestRule.setContent { + AppThemeSurface { + EditInvoiceContent( + amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), + noteText = "", + isSoftKeyboardVisible = false, + keyboardVisible = false, + tags = persistentListOf(), + onBack = {}, + onContinueKeyboard = {}, + onClickBalance = {}, + onContinueGeneral = {}, + onClickAddTag = {}, + onTextChanged = {}, + onClickTag = {}, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestSendButton").assertDoesNotExist() + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 1bccf9cb8b..1af88311cf 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -70,6 +70,8 @@ data class PaykitPaymentRequest( val expiresAt: Instant?, val acceptedPaymentEndpointIdentifiers: List, val deliveryStatus: PaykitPaymentRequestDeliveryStatus? = null, + val direction: PaykitPaymentRequestDirection = PaykitPaymentRequestDirection.Incoming, + val lifecycleState: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, ) { val id: PaykitPaymentRequestId get() = PaykitPaymentRequestId(paymentRequestId, counterparty, counterpartyReceiverPath) @@ -87,6 +89,8 @@ data class PaykitPaymentRequest( enum class PaykitPaymentRequestDeliveryStatus { Queued, Sent } +enum class PaykitPaymentRequestDirection { Incoming, Outgoing } + data class PaykitPaymentRequestTarget( val publicKey: String, val receiverPath: String, @@ -132,8 +136,8 @@ class PaykitPaymentRequestRepo @Inject constructor( private var expirationJob: Job? = null private val _pendingRequests = MutableStateFlow>(emptyList()) val pendingRequests: StateFlow> = _pendingRequests.asStateFlow() - private val _sentRequests = MutableStateFlow>(emptyList()) - val sentRequests: StateFlow> = _sentRequests.asStateFlow() + private val _paymentRequestHistory = MutableStateFlow>(emptyList()) + val paymentRequestHistory: StateFlow> = _paymentRequestHistory.asStateFlow() private val _eligibleTargets = MutableStateFlow>(emptyList()) val eligibleTargets: StateFlow> = _eligibleTargets.asStateFlow() private val _isCreatingRequest = MutableStateFlow(false) @@ -259,7 +263,7 @@ class PaykitPaymentRequestRepo @Inject constructor( ): PaykitPaymentRequestCreation { val wasPublishedToActiveState = isCurrentState(generation, creatorIdentity) if (wasPublishedToActiveState) { - _sentRequests.update { requests -> + _paymentRequestHistory.update { requests -> listOf(request) + requests.filterNot { it.id == request.id } } scheduleExpirationLocked() @@ -267,7 +271,10 @@ class PaykitPaymentRequestRepo @Inject constructor( return PaykitPaymentRequestCreation(request, creatorIdentity, wasPublishedToActiveState) } - suspend fun accept(request: PaykitPaymentRequest): Result = updateRequest(request) { + suspend fun accept(request: PaykitPaymentRequest): Result = updateRequest( + request = request, + resultingState = PaymentRequestLifecycleState.ACCEPTED, + ) { paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, counterpartyReceiverPath = it.counterpartyReceiverPath, @@ -277,7 +284,10 @@ class PaykitPaymentRequestRepo @Inject constructor( Logger.warn("Failed to accept incoming Paykit payment request", it, context = TAG) } - suspend fun reject(request: PaykitPaymentRequest): Result = updateRequest(request) { + suspend fun reject(request: PaykitPaymentRequest): Result = updateRequest( + request = request, + resultingState = PaymentRequestLifecycleState.REJECTED, + ) { paykitSdkService.rejectPaymentRequest( counterparty = it.counterparty, counterpartyReceiverPath = it.counterpartyReceiverPath, @@ -315,12 +325,10 @@ class PaykitPaymentRequestRepo @Inject constructor( processPendingMessages() paykitSdkService.receivePrivateMessagesFromLinkedPeers().also(::logIntakeFailures) val now = clock.now() - val incoming = paykitSdkService.actionableReceivedPaymentRequests().mapNotNull { - it.toPaykitPaymentRequest(PaymentRequestLocalRole.PAYER, now) - } - val sent = paykitSdkService.paymentRequests().mapNotNull { - it.toPaykitPaymentRequest(PaymentRequestLocalRole.PAYEE, now) - } + val records = paykitSdkService.paymentRequests() + val incoming = records.mapNotNull { it.toPaykitPaymentRequest(PaymentRequestLocalRole.PAYER, now) } + val history = records.mapNotNull { it.toPaykitPaymentRequestHistory(now) } + .sortedByDescending { it.createdAt } val targets = expectedIdentity?.let { eligibleTargets(savedPublicKeys, it) }.orEmpty() if ( stateGeneration.get() != generation || @@ -329,7 +337,7 @@ class PaykitPaymentRequestRepo @Inject constructor( return } _pendingRequests.update { incoming } - _sentRequests.update { sent } + _paymentRequestHistory.update { history } _eligibleTargets.update { targets } prunePresentedRequestIds(incoming) scheduleExpirationLocked() @@ -394,6 +402,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun updateRequest( request: PaykitPaymentRequest, + resultingState: PaymentRequestLifecycleState, operation: suspend (PaykitPaymentRequest) -> Unit, ): Result = withContext(ioDispatcher) { runSuspendCatching { @@ -409,6 +418,10 @@ class PaykitPaymentRequestRepo @Inject constructor( ?: throw PaykitPaymentRequestError.RequestUnavailable operation(current) + val updatedRequest = current.copy(lifecycleState = resultingState) + _paymentRequestHistory.update { requests -> + listOf(updatedRequest) + requests.filterNot { it.id == current.id } + } _pendingRequests.update { requests -> requests.filterNot { it.id == current.id } } discardExpiredRequestsLocked() processPendingMessages() @@ -459,7 +472,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun discardExpiredRequestsLocked() { val now = clock.now() _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } - _sentRequests.update { requests -> requests.filterNot { it.isExpired(now) } } + _paymentRequestHistory.update { requests -> requests.withExpiredLifecycle(now) } prunePresentedRequestIds(_pendingRequests.value) scheduleExpirationLocked() } @@ -478,7 +491,7 @@ class PaykitPaymentRequestRepo @Inject constructor( expirationJob?.cancel() expirationJob = null _pendingRequests.update { emptyList() } - _sentRequests.update { emptyList() } + _paymentRequestHistory.update { emptyList() } _eligibleTargets.update { emptyList() } } @@ -486,7 +499,9 @@ class PaykitPaymentRequestRepo @Inject constructor( expirationJob?.cancel() expirationJob = null - val nextExpiration = (_pendingRequests.value + _sentRequests.value) + val nextExpiration = (_pendingRequests.value + _paymentRequestHistory.value) + .asSequence() + .filter { it.lifecycleState == PaymentRequestLifecycleState.PROPOSED } .mapNotNull { it.expiresAt } .minOrNull() ?: return @@ -501,14 +516,24 @@ class PaykitPaymentRequestRepo @Inject constructor( } } +private fun List.withExpiredLifecycle(now: Instant): List = map { request -> + if (request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && request.isExpired(now)) { + request.copy(lifecycleState = PaymentRequestLifecycleState.PROPOSAL_EXPIRED) + } else { + request + } +} + private val bitcoinAmountPattern = Regex("(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)") @Suppress("CyclomaticComplexMethod", "ReturnCount") private fun PaymentRequestRecord.toPaykitPaymentRequest( expectedRole: PaymentRequestLocalRole, now: Instant, + requiresActionableRequest: Boolean = true, ): PaykitPaymentRequest? { - if (localRole != expectedRole || state != PaymentRequestLifecycleState.PROPOSED) return null + if (localRole != expectedRole || state == PaymentRequestLifecycleState.ACTIVE_RECURRING) return null + if (requiresActionableRequest && state != PaymentRequestLifecycleState.PROPOSED) return null val requestTerms = terms ?: return null if (requestTerms.recurrence != null || requestTerms.amount.asset != "btc") return null val amountSats = requestTerms.amount.value.toSats() @@ -517,12 +542,12 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( val endpoints = requestTerms.acceptedPaymentEndpointIdentifiers .filter { MethodId.fromRawValue(it) != null } .distinct() - if (endpoints.isEmpty()) return null + if (requiresActionableRequest && endpoints.isEmpty()) return null val expiresAt = requestTerms.proposalExpiresAt?.let { runCatching { Instant.parse(it) }.getOrNull() ?: return null } - if (expiresAt != null && expiresAt <= now) return null + if (requiresActionableRequest && expiresAt != null && expiresAt <= now) return null return PaykitPaymentRequest( paymentRequestId = paymentRequestId, @@ -543,9 +568,25 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest( } else { null }, + direction = if (expectedRole == PaymentRequestLocalRole.PAYER) { + PaykitPaymentRequestDirection.Incoming + } else { + PaykitPaymentRequestDirection.Outgoing + }, + lifecycleState = if (state == PaymentRequestLifecycleState.PROPOSED && expiresAt?.let { it <= now } == true) { + PaymentRequestLifecycleState.PROPOSAL_EXPIRED + } else { + state + }, ) } +private fun PaymentRequestRecord.toPaykitPaymentRequestHistory(now: Instant): PaykitPaymentRequest? { + val role = localRole ?: return null + if (role == PaymentRequestLocalRole.UNKNOWN) return null + return toPaykitPaymentRequest(role, now, requiresActionableRequest = false) +} + private fun PaymentRequestRecord.toCreatedPaykitPaymentRequest( draft: PaykitPaymentRequestDraft, target: PaykitPaymentRequestTarget, @@ -575,6 +616,8 @@ private fun PaymentRequestRecord.toCreatedPaykitPaymentRequest( } else { PaykitPaymentRequestDeliveryStatus.Queued }, + direction = PaykitPaymentRequestDirection.Outgoing, + lifecycleState = PaymentRequestLifecycleState.PROPOSED, ) } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 18e8ad4fc6..b6b34e08a1 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -580,13 +580,6 @@ class PaykitSdkService @Inject constructor( } } - suspend fun actionableReceivedPaymentRequests(): List { - isSetup.await() - return operationMutex.withLock { - handle().actionableReceivedPaymentRequests() - } - } - suspend fun paymentRequests(): List { isSetup.await() return operationMutex.withLock { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index baa2b1e79c..f6040b8ae1 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -731,6 +731,9 @@ private fun RootNavHost( PaymentRequestsScreen( appViewModel = appViewModel, onBack = { navController.popBackStack() }, + onRequestPayment = { + appViewModel.showSheet(Sheet.Receive(route = ReceiveRoute.PaymentRequestDetails)) + }, ) } settings(navController, settingsViewModel) diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt new file mode 100644 index 0000000000..046439b76a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt @@ -0,0 +1,81 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.layout.Arrangement +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.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +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.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors + +@Composable +fun PubkyContactRow( + profile: PubkyProfile, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isSelected: Boolean? = null, + isEnabled: Boolean = true, + verticalPadding: Dp = 12.dp, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .fillMaxWidth() + .clickableAlpha(enabled = isEnabled, onClick = onClick) + .then( + if (isSelected == null) { + Modifier + } else { + Modifier.semantics { + role = Role.RadioButton + selected = isSelected + if (!isEnabled) disabled() + } + } + ) + .padding(vertical = verticalPadding) + ) { + PubkyContactAvatar(profile = profile) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) + ) { + BodyS( + text = profile.truncatedPublicKey, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BodySSB( + text = profile.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (isSelected == true) { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = null, + tint = Colors.PubkyGreen, + modifier = Modifier.size(24.dp) + ) + } + } +} 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 31534b4ce0..4007f0aaed 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 @@ -8,10 +8,8 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.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 @@ -19,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -27,40 +26,47 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.AmountInputHandler import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS -import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display -import to.bitkit.ui.components.MoneyStack +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.PubkyContactRow +import to.bitkit.ui.components.SearchInput import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.AmountInputViewModel @@ -95,10 +101,27 @@ fun PaymentRequestDetailsScreen( initialDraft: PaykitPaymentRequestDraft, onBack: () -> Unit, onContinue: (PaykitPaymentRequestDraft) -> Unit, +) { + PaymentRequestDetailsContent( + amountInputViewModel = amountInputViewModel, + initialDraft = initialDraft, + onBack = onBack, + onContinue = onContinue, + ) +} + +@Composable +internal fun PaymentRequestDetailsContent( + modifier: Modifier = Modifier, + amountInputViewModel: AmountInputViewModel, + initialDraft: PaykitPaymentRequestDraft, + onBack: () -> Unit, + onContinue: (PaykitPaymentRequestDraft) -> Unit, ) { val currencies = LocalCurrencies.current val amountState by amountInputViewModel.uiState.collectAsStateWithLifecycle() var note by remember(initialDraft.note) { mutableStateOf(initialDraft.note) } + var isEditingAmount by remember { mutableStateOf(false) } var expiration by remember(initialDraft.expiresAt) { mutableStateOf(PaymentRequestExpiration.from(initialDraft.expiresAt, Clock.System.now())) } @@ -111,7 +134,7 @@ fun PaymentRequestDetailsScreen( } Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .gradientBackground() .navigationBarsPadding() @@ -123,71 +146,102 @@ fun PaymentRequestDetailsScreen( onBack = onBack, ) Caption13Up(text = stringResource(R.string.wallet__payment_request_amount), color = Colors.White64) - Spacer(Modifier.height(8.dp)) - NumberPadTextField( - viewModel = amountInputViewModel, - modifier = Modifier.testTag("PaymentRequestAmountField"), - ) - Spacer(Modifier.height(20.dp)) - Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) - Spacer(Modifier.height(8.dp)) - TextInput( - value = note, - onValueChange = { note = it.take(256) }, - placeholder = stringResource(R.string.wallet__payment_request_note_placeholder), - maxLines = 2, - modifier = Modifier - .fillMaxWidth() - .testTag("PaymentRequestNote"), - ) - Spacer(Modifier.height(20.dp)) - Caption13Up(text = stringResource(R.string.wallet__payment_request_expires), color = Colors.White64) - Spacer(Modifier.height(8.dp)) - Row(modifier = Modifier.fillMaxWidth()) { - PaymentRequestExpiration.entries.forEach { option -> - val isSelected = option == expiration - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .weight(1f) - .clickableAlpha { expiration = option } - .semantics { - role = Role.RadioButton - selected = isSelected - } - .testTag("PaymentRequestExpiry_${option.name}"), - ) { - BodyS(text = option.title(), color = if (isSelected) Colors.White else Colors.White64) - Spacer(Modifier.height(8.dp)) - HorizontalDivider( - thickness = 2.dp, - color = if (isSelected) Colors.Purple else Colors.White16, - ) - } + VerticalSpacer(8.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + NumberPadTextField( + viewModel = amountInputViewModel, + onClick = { isEditingAmount = true }, + modifier = Modifier + .weight(1f) + .testTag("PaymentRequestAmountField"), + ) + IconButton( + onClick = { isEditingAmount = true }, + modifier = Modifier + .size(48.dp) + .testTag("PaymentRequestEditAmount"), + ) { + Icon( + painter = painterResource(R.drawable.ic_pencil_simple), + contentDescription = stringResource(R.string.common__edit), + tint = Colors.White, + modifier = Modifier.size(24.dp), + ) } } - Spacer(Modifier.weight(1f)) - NumberPad( - viewModel = amountInputViewModel, - availableHeight = 210.dp, - modifier = Modifier.testTag("PaymentRequestNumberPad"), - ) - Spacer(Modifier.height(12.dp)) - PrimaryButton( - text = stringResource(R.string.wallet__payment_request_choose_recipient), - enabled = amountState.sats > 0, - onClick = { - onContinue( - PaykitPaymentRequestDraft( - amountSats = amountState.sats.toULong(), - note = note.trim(), - expiresAt = Clock.System.now() + expiration.duration, + if (isEditingAmount) { + FillHeight() + NumberPad( + viewModel = amountInputViewModel, + availableHeight = 210.dp, + modifier = Modifier.testTag("PaymentRequestNumberPad"), + ) + VerticalSpacer(12.dp) + PrimaryButton( + text = stringResource(R.string.common__continue), + onClick = { isEditingAmount = false }, + modifier = Modifier.testTag("PaymentRequestAmountDone"), + ) + } else { + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_note), color = Colors.White64) + VerticalSpacer(8.dp) + TextInput( + value = note, + onValueChange = { note = it.take(256) }, + placeholder = stringResource(R.string.wallet__payment_request_note_placeholder), + maxLines = 2, + modifier = Modifier + .fillMaxWidth() + .testTag("PaymentRequestNote"), + ) + VerticalSpacer(20.dp) + Caption13Up(text = stringResource(R.string.wallet__payment_request_expires), color = Colors.White64) + VerticalSpacer(8.dp) + Row(modifier = Modifier.fillMaxWidth()) { + PaymentRequestExpiration.entries.forEach { option -> + val isSelected = option == expiration + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .clickableAlpha { expiration = option } + .semantics { + role = Role.RadioButton + selected = isSelected + } + .testTag("PaymentRequestExpiry${option.name}"), + ) { + BodyS(text = option.title(), color = if (isSelected) Colors.White else Colors.White64) + VerticalSpacer(8.dp) + HorizontalDivider( + thickness = 2.dp, + color = if (isSelected) Colors.White else Colors.White16, + ) + } + } + } + FillHeight() + PrimaryButton( + text = stringResource(R.string.wallet__payment_request_choose_recipient), + enabled = amountState.sats > 0, + onClick = { + onContinue( + PaykitPaymentRequestDraft( + amountSats = amountState.sats.toULong(), + note = note.trim(), + expiresAt = Clock.System.now() + expiration.duration, + ) ) - ) - }, - modifier = Modifier.testTag("PaymentRequestAmountContinue"), - ) - Spacer(Modifier.height(16.dp)) + }, + modifier = Modifier.testTag("PaymentRequestAmountContinue"), + ) + } + VerticalSpacer(16.dp) } } @@ -201,15 +255,46 @@ fun PaymentRequestRecipientScreen( val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + + PaymentRequestRecipientContent( + targets = targets.toImmutableList(), + contacts = contacts.toImmutableList(), + isCreating = isCreating, + onBack = onBack, + onSend = { target -> appViewModel.createPaymentRequest(draft, target, onSent) }, + ) +} + +@Composable +internal fun PaymentRequestRecipientContent( + modifier: Modifier = Modifier, + targets: ImmutableList, + contacts: ImmutableList, + isCreating: Boolean, + onBack: () -> Unit, + onSend: (PaykitPaymentRequestTarget) -> Unit, +) { var selectedTarget by remember { mutableStateOf(null) } + var query by remember { mutableStateOf("") } + + val recipients = remember(targets, contacts, query) { + targets.mapNotNull { target -> + contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, target.publicKey) } + ?.let { target to it } + }.filter { (target, contact) -> + query.isBlank() || + target.publicKey.contains(query.trim(), ignoreCase = true) || + contact.name.contains(query.trim(), ignoreCase = true) + } + } - LaunchedEffect(targets) { - if (selectedTarget !in targets) selectedTarget = null + LaunchedEffect(recipients) { + if (recipients.none { (target, _) -> target == selectedTarget }) selectedTarget = null } BackHandler(enabled = isCreating) {} Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .gradientBackground() .navigationBarsPadding() @@ -220,15 +305,32 @@ fun PaymentRequestRecipientScreen( titleText = stringResource(R.string.wallet__payment_request_choose_recipient), onBack = if (isCreating) null else onBack, ) + Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) + VerticalSpacer(8.dp) + SearchInput( + value = query, + onValueChange = { query = it }, + placeholder = stringResource(R.string.wallet__payment_request_enter_pubky), + modifier = Modifier + .fillMaxWidth() + .testTag("PaymentRequestRecipientSearch"), + ) + VerticalSpacer(24.dp) + Caption13Up(text = stringResource(R.string.contacts__contacts_header), color = Colors.White64) + VerticalSpacer(8.dp) + HorizontalDivider(color = Colors.White10) LazyColumn(modifier = Modifier.weight(1f)) { - items(targets, key = { "${it.publicKey}|${it.receiverPath}" }) { target -> - val contact = contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, target.publicKey) } - ?: PubkyProfile.placeholder(target.publicKey) - PaymentRequestContactRow( - contact = contact, - selected = target == selectedTarget, - enabled = !isCreating, + items( + items = recipients, + key = { (target, _) -> "${target.publicKey}|${target.receiverPath}" }, + ) { (target, contact) -> + PubkyContactRow( + profile = contact, onClick = { selectedTarget = target }, + isSelected = target == selectedTarget, + isEnabled = !isCreating, + verticalPadding = 16.dp, + modifier = Modifier.testTag("PaymentRequestContact${contact.publicKey}"), ) HorizontalDivider(color = Colors.White10) } @@ -239,64 +341,35 @@ fun PaymentRequestRecipientScreen( isLoading = isCreating, onClick = { val target = selectedTarget ?: return@PrimaryButton - appViewModel.createPaymentRequest(draft, target, onSent) + onSend(target) }, modifier = Modifier.testTag("PaymentRequestSend"), ) - Spacer(Modifier.height(16.dp)) + VerticalSpacer(16.dp) } } @Composable -private fun PaymentRequestContactRow( - contact: PubkyProfile, - selected: Boolean, - enabled: Boolean, - onClick: () -> Unit, +fun PaymentRequestSentScreen( + appViewModel: AppViewModel, + request: PaykitPaymentRequest, + onDone: () -> Unit, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxWidth() - .clickableAlpha(enabled = enabled, onClick = onClick) - .semantics { - role = Role.RadioButton - this.selected = selected - if (!enabled) disabled() - } - .padding(vertical = 16.dp) - .testTag("PaymentRequestContact_${contact.publicKey}"), - ) { - PubkyContactAvatar(profile = contact) - Column(modifier = Modifier.weight(1f)) { - BodyS( - text = contact.truncatedPublicKey, - color = Colors.White64, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BodySSB(text = contact.name, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - if (selected) { - Icon( - painter = painterResource(R.drawable.ic_check), - contentDescription = null, - tint = Colors.Purple, - modifier = Modifier.size(24.dp), - ) - } - } + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val contact = contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, request.counterparty) } + PaymentRequestSentContent(request = request, contact = contact, onDone = onDone) } @Composable -fun PaymentRequestSentScreen( +internal fun PaymentRequestSentContent( + modifier: Modifier = Modifier, request: PaykitPaymentRequest, + contact: PubkyProfile?, onDone: () -> Unit, ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier + horizontalAlignment = Alignment.Start, + modifier = modifier .fillMaxSize() .gradientBackground() .navigationBarsPadding() @@ -304,34 +377,43 @@ fun PaymentRequestSentScreen( .testTag("PaymentRequestSent"), ) { SheetTopBar(titleText = stringResource(R.string.wallet__payment_request_sent_title)) - MoneyStack(sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()) - Spacer(Modifier.weight(1f)) + VerticalSpacer(32.dp) Image( painter = painterResource(R.drawable.check), contentDescription = null, - contentScale = ContentScale.FillWidth, modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .align(Alignment.CenterHorizontally) + .size(256.dp) + .testTag("PaymentRequestSentCheck"), + ) + VerticalSpacer(32.dp) + Display( + text = stringResource(R.string.wallet__payment_request_sent_headline) + .withAccent(accentColor = Colors.Purple), ) - Spacer(Modifier.height(24.dp)) - Display(text = stringResource(R.string.wallet__payment_request_sent_headline).withAccent()) - Spacer(Modifier.height(12.dp)) + VerticalSpacer(12.dp) BodyM( - text = if (request.deliveryStatus == to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus.Sent) { - stringResource(R.string.wallet__payment_request_sent_description) + text = stringResource(R.string.wallet__payment_request_sent_description), + color = Colors.White64, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth(), + ) + VerticalSpacer(24.dp) + PaymentRequestCard( + request = request, + contact = contact, + compactSubtitle = if (request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + stringResource(R.string.wallet__payment_request_waiting) } else { - stringResource(R.string.wallet__payment_request_queued_description) + stringResource(R.string.wallet__payment_request_sending) }, - color = Colors.White64, - textAlign = TextAlign.Center, ) - Spacer(Modifier.weight(1f)) + VerticalSpacer(32.dp) PrimaryButton( text = stringResource(R.string.common__ok), onClick = onDone, ) - Spacer(Modifier.height(16.dp)) + VerticalSpacer(16.dp) } } @@ -344,3 +426,74 @@ private fun PaymentRequestExpiration.title(): String = stringResource( PaymentRequestExpiration.Month -> R.string.wallet__payment_request_expiry_month } ) + +private val previewDraft = PaykitPaymentRequestDraft( + amountSats = 25_000uL, + note = "Dinner", + expiresAt = Instant.parse("2027-01-15T09:00:00Z"), +) + +private val previewTarget = PaykitPaymentRequestTarget( + publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", + receiverPath = "bitkit/wallet", +) + +private val previewCreatedRequest = PaykitPaymentRequest( + paymentRequestId = "payment-request", + counterparty = previewTarget.publicKey, + counterpartyReceiverPath = previewTarget.receiverPath, + amountValue = "0.00025", + amountSats = previewDraft.amountSats, + note = previewDraft.note, + createdAt = Instant.parse("2027-01-15T08:00:00Z"), + expiresAt = previewDraft.expiresAt, + acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), +) + +@Preview(showSystemUi = true) +@Composable +private fun PaymentRequestDetailsPreview() { + AppThemeSurface { + BottomSheetPreview { + PaymentRequestDetailsContent( + amountInputViewModel = AmountInputViewModel(AmountInputHandler.stub()), + initialDraft = previewDraft, + onBack = {}, + onContinue = {}, + modifier = Modifier.sheetHeight(), + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PaymentRequestRecipientPreview() { + AppThemeSurface { + BottomSheetPreview { + PaymentRequestRecipientContent( + targets = persistentListOf(previewTarget), + contacts = persistentListOf(PubkyProfile.placeholder(previewTarget.publicKey)), + isCreating = false, + onBack = {}, + onSend = {}, + modifier = Modifier.sheetHeight(), + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PaymentRequestSentPreview() { + AppThemeSurface { + BottomSheetPreview { + PaymentRequestSentContent( + request = previewCreatedRequest, + contact = PubkyProfile.placeholder(previewTarget.publicKey), + onDone = {}, + modifier = Modifier.sheetHeight(), + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index 0b32238382..f89e80511d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -6,10 +6,8 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.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 @@ -33,33 +31,51 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.synonym.paykit.PaymentRequestLifecycleState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import to.bitkit.R -import to.bitkit.ext.formatInvoiceExpiryRelative +import to.bitkit.ext.UiDateStyle import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestDirection +import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.MoneySSB import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.shared.util.outerGlow +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.uiDateText import to.bitkit.viewmodels.AppViewModel -import java.text.DateFormat -import java.util.Date +import java.time.YearMonth +import java.time.ZoneId +import java.time.format.TextStyle +import java.util.Locale import kotlin.time.Clock import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import java.time.Instant as JavaInstant @Composable fun PaymentRequestsSheet( @@ -74,8 +90,28 @@ fun PaymentRequestsSheet( if (requests.isEmpty()) onNotNow() } + PaymentRequestsSheetContent( + requests = requests.toImmutableList(), + contacts = contacts.toImmutableList(), + onNotNow = onNotNow, + onSeeAll = onSeeAll, + onPay = appViewModel::openIncomingPaymentRequest, + onReject = appViewModel::rejectIncomingPaymentRequest, + ) +} + +@Composable +internal fun PaymentRequestsSheetContent( + modifier: Modifier = Modifier, + requests: ImmutableList, + contacts: ImmutableList, + onNotNow: () -> Unit, + onSeeAll: () -> Unit, + onPay: (PaykitPaymentRequestId) -> Unit, + onReject: suspend (PaykitPaymentRequest) -> Result, +) { Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .gradientBackground() .navigationBarsPadding() @@ -88,7 +124,7 @@ fun PaymentRequestsSheet( color = Colors.White64, modifier = Modifier.align(Alignment.CenterHorizontally), ) - Spacer(Modifier.height(24.dp)) + VerticalSpacer(24.dp) LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.weight(1f), @@ -97,8 +133,8 @@ fun PaymentRequestsSheet( PaymentRequestCard( request = request, contact = contacts.contactFor(request), - onPay = { appViewModel.openIncomingPaymentRequest(request.id) }, - onReject = { appViewModel.rejectIncomingPaymentRequest(request) }, + onPay = { onPay(request.id) }, + onReject = { onReject(request) }, ) } } @@ -116,7 +152,7 @@ fun PaymentRequestsSheet( .testTag("PaymentRequestsSeeAll"), ) } - Spacer(Modifier.height(16.dp)) + VerticalSpacer(16.dp) } } @@ -124,13 +160,42 @@ fun PaymentRequestsSheet( fun PaymentRequestsScreen( appViewModel: AppViewModel, onBack: () -> Unit, + onRequestPayment: () -> Unit, ) { - val incoming by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() - val sent by appViewModel.sentPaymentRequests.collectAsStateWithLifecycle() + val pending by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() + val history by appViewModel.paymentRequestHistory.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() + + PaymentRequestsContent( + requests = (pending + history).distinctBy { it.id }.toImmutableList(), + pending = pending.toImmutableList(), + contacts = contacts.toImmutableList(), + canRequestPayment = targets.isNotEmpty(), + onBack = onBack, + onRequestPayment = onRequestPayment, + onPay = appViewModel::openIncomingPaymentRequest, + onReject = appViewModel::rejectIncomingPaymentRequest, + ) +} + +@Composable +internal fun PaymentRequestsContent( + modifier: Modifier = Modifier, + requests: ImmutableList, + pending: ImmutableList, + contacts: ImmutableList, + canRequestPayment: Boolean, + onBack: () -> Unit, + onRequestPayment: () -> Unit, + onPay: (to.bitkit.repositories.PaykitPaymentRequestId) -> Unit, + onReject: suspend (PaykitPaymentRequest) -> Result, +) { + val sections = paymentRequestSections(requests, pending, Clock.System.now()) + val currentMonth = YearMonth.now() Column( - modifier = Modifier + modifier = modifier .fillMaxSize() .gradientBackground() .navigationBarsPadding() @@ -139,14 +204,15 @@ fun PaymentRequestsScreen( AppTopBar( titleText = stringResource(R.string.wallet__payment_requests), onBackClick = onBack, + actions = { DrawerNavIcon() }, ) LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .fillMaxSize() + .weight(1f) .padding(horizontal = 16.dp), ) { - if (incoming.isEmpty() && sent.isEmpty()) { + if (requests.isEmpty()) { item { BodyM( text = stringResource(R.string.wallet__payment_requests_empty), @@ -157,72 +223,216 @@ fun PaymentRequestsScreen( ) } } - if (incoming.isNotEmpty()) { + if (sections.active.isNotEmpty()) { item { Caption13Up( - text = stringResource(R.string.wallet__payment_requests_incoming), + text = stringResource(R.string.wallet__payment_requests_section), color = Colors.White64, ) } - items(incoming, key = { it.lazyListKey }) { request -> - PaymentRequestCard( + items(sections.active, key = { it.lazyListKey }) { request -> + ActivePaymentRequestCard( request = request, + isIncoming = pending.any { it.id == request.id }, contact = contacts.contactFor(request), - onPay = { appViewModel.openIncomingPaymentRequest(request.id) }, - onReject = { appViewModel.rejectIncomingPaymentRequest(request) }, + onPay = onPay, + onReject = onReject, ) } } - if (sent.isNotEmpty()) { - item { + sections.historyByMonth.forEach { (month, history) -> + item(key = "history-${month ?: "earlier"}") { Caption13Up( - text = stringResource(R.string.wallet__payment_requests_sent), + text = paymentRequestHistorySectionTitle(month, currentMonth), color = Colors.White64, ) } - items(sent, key = { it.lazyListKey }) { request -> + items(history, key = { it.lazyListKey }) { request -> PaymentRequestCard( request = request, contact = contacts.contactFor(request), - status = if (request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { - stringResource(R.string.wallet__payment_request_waiting) - } else { - stringResource(R.string.wallet__payment_request_sending) - }, + compactSubtitle = paymentRequestDate(request), ) } } - item { Spacer(Modifier.height(96.dp)) } + item { VerticalSpacer(8.dp) } + } + if (canRequestPayment) { + PrimaryButton( + text = stringResource(R.string.wallet__payment_request_request_payment), + onClick = onRequestPayment, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("PaymentRequestCreate"), + ) + VerticalSpacer(16.dp) } } } +private data class PaymentRequestSections( + val active: List, + val historyByMonth: Map>, +) + +private fun paymentRequestSections( + requests: List, + pending: List, + now: Instant, +): PaymentRequestSections { + val pendingIds = pending.mapTo(mutableSetOf()) { it.id } + val active = requests.filter { request -> + request.id in pendingIds || + request.direction == PaykitPaymentRequestDirection.Outgoing && + request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && + !request.isExpired(now) + } + val activeIds = active.mapTo(mutableSetOf()) { it.id } + val historyByMonth = requests.filterNot { it.id in activeIds }.groupBy { it.historyMonth() } + return PaymentRequestSections(active, historyByMonth) +} + @Composable -private fun PaymentRequestCard( +private fun ActivePaymentRequestCard( request: PaykitPaymentRequest, + isIncoming: Boolean, contact: PubkyProfile?, - status: String? = null, + onPay: (to.bitkit.repositories.PaykitPaymentRequestId) -> Unit, + onReject: suspend (PaykitPaymentRequest) -> Result, +) { + if (isIncoming) { + PaymentRequestCard( + request = request, + contact = contact, + compactSubtitle = paymentRequestDateTime(request), + onPay = { onPay(request.id) }, + onReject = { onReject(request) }, + ) + } else { + PaymentRequestCard( + request = request, + contact = contact, + compactSubtitle = stringResource( + R.string.wallet__payment_request_waiting_for_recipient, + contact?.name ?: PubkyProfile.placeholder(request.counterparty).name, + ), + ) + } +} + +@Composable +private fun paymentRequestHistorySectionTitle(month: YearMonth?, currentMonth: YearMonth): String = when { + month == currentMonth -> stringResource(R.string.wallet__payment_requests_this_month) + month != null -> month.sectionTitle(currentMonth) + else -> stringResource(R.string.wallet__payment_requests_earlier) +} + +private fun PaykitPaymentRequest.historyMonth(): YearMonth? = createdAt?.let { + YearMonth.from(JavaInstant.ofEpochMilli(it.toEpochMilliseconds()).atZone(ZoneId.systemDefault())) +} + +@Composable +private fun paymentRequestDate(request: PaykitPaymentRequest): String = request.createdAt?.let { + uiDateText(it.epochSeconds.toULong(), UiDateStyle.DATE) +} ?: paymentRequestStatus(request) + +@Composable +private fun paymentRequestDateTime(request: PaykitPaymentRequest): String = request.createdAt?.let { + val timestamp = it.epochSeconds.toULong() + stringResource( + R.string.wallet__payment_request_timestamp, + uiDateText(timestamp, UiDateStyle.DATE), + uiDateText(timestamp, UiDateStyle.TIME), + ) +} ?: paymentRequestStatus(request) + +@Composable +private fun YearMonth.sectionTitle( + currentMonth: YearMonth, + locale: Locale = Locale.getDefault(), +): String { + val monthName = month.getDisplayName(TextStyle.FULL, locale) + return if (year == currentMonth.year) { + monthName + } else { + stringResource(R.string.wallet__payment_requests_month_year, monthName, year) + } +} + +@Composable +private fun paymentRequestStatus(request: PaykitPaymentRequest): String { + if (request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && request.isExpired(Clock.System.now())) { + return stringResource(R.string.wallet__payment_request_status_expired) + } + + return when (request.lifecycleState) { + PaymentRequestLifecycleState.PROPOSED -> { + if (request.direction == PaykitPaymentRequestDirection.Incoming) { + stringResource(R.string.wallet__payment_request_status_unavailable) + } else if (request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { + stringResource(R.string.wallet__payment_request_waiting) + } else { + stringResource(R.string.wallet__payment_request_sending) + } + } + PaymentRequestLifecycleState.PROPOSAL_EXPIRED -> + stringResource(R.string.wallet__payment_request_status_expired) + PaymentRequestLifecycleState.ACCEPTED -> + stringResource(R.string.wallet__payment_request_status_accepted) + PaymentRequestLifecycleState.REJECTED -> + stringResource(R.string.wallet__payment_request_status_rejected) + PaymentRequestLifecycleState.CANCELED -> + stringResource(R.string.wallet__payment_request_status_canceled) + PaymentRequestLifecycleState.PROOF_SUBMITTED -> + stringResource(R.string.wallet__payment_request_status_proof_submitted) + PaymentRequestLifecycleState.RECOVERY_REQUIRED -> + stringResource(R.string.wallet__payment_request_status_action_required) + PaymentRequestLifecycleState.INVALID_CONFLICT, + PaymentRequestLifecycleState.ACTIVE_RECURRING, + PaymentRequestLifecycleState.UNKNOWN, + -> stringResource(R.string.wallet__payment_request_status_unavailable) + } +} + +@Composable +internal fun PaymentRequestCard( + request: PaykitPaymentRequest, + contact: PubkyProfile?, + compactSubtitle: String? = null, onPay: (() -> Unit)? = null, onReject: (suspend () -> Result)? = null, ) { val scope = rememberCoroutineScope() var isRejecting by remember(request.id) { mutableStateOf(false) } val displayContact = contact ?: PubkyProfile.placeholder(request.counterparty) - val subtitle = remember(request.createdAt, displayContact.name) { - request.createdAt?.let { - val timestamp = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) - .format(Date(it.toEpochMilliseconds())) - "${displayContact.name} · $timestamp" - } ?: displayContact.name - } + val subtitle = compactSubtitle ?: request.createdAt?.let { + val timestamp = it.epochSeconds.toULong() + val date = uiDateText(timestamp, UiDateStyle.DATE) + val time = uiDateText(timestamp, UiDateStyle.TIME) + val formattedTimestamp = stringResource(R.string.wallet__payment_request_timestamp, date, time) + stringResource(R.string.wallet__payment_request_contact_timestamp, displayContact.name, formattedTimestamp) + } ?: displayContact.name Card( colors = CardDefaults.cardColors(containerColor = Colors.Gray6), shape = MaterialTheme.shapes.medium, modifier = Modifier .fillMaxWidth() - .border(1.dp, Colors.Purple.copy(alpha = 0.32f), MaterialTheme.shapes.medium) - .testTag("PaymentRequestRow_${request.paymentRequestId}"), + .then( + if (onPay != null || onReject != null) { + Modifier + .outerGlow( + glowColor = Colors.Brand, + glowOpacity = 0.16f, + glowRadius = 64.dp, + cornerRadius = 16.dp, + ) + .border(1.dp, Colors.Brand.copy(alpha = 0.5f), MaterialTheme.shapes.medium) + } else { + Modifier + } + ) + .testTag("PaymentRequestRow${request.paymentRequestId}"), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -248,29 +458,14 @@ private fun PaymentRequestCard( showSymbol = true, ) } - if (status != null) { - HorizontalDivider(color = Colors.White10) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - BodyS(text = status, color = Colors.Purple) - Spacer(Modifier.weight(1f)) - request.expiresAt?.let { - val remainingSeconds = (it - Clock.System.now()).inWholeSeconds.coerceAtLeast(0).toULong() - BodyS(text = formatInvoiceExpiryRelative(remainingSeconds), color = Colors.White64) - } - } - } else if (onPay != null || onReject != null) { + if (onPay != null || onReject != null) { HorizontalDivider(color = Colors.White10) Row( horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(16.dp), ) { SecondaryButton( - text = stringResource(R.string.wallet__payment_request_reject), + text = stringResource(R.string.wallet__payment_request_dismiss), onClick = { if (isRejecting || onReject == null) return@SecondaryButton isRejecting = true @@ -315,3 +510,56 @@ private fun List.contactFor(request: PaykitPaymentRequest): PubkyP private val PaykitPaymentRequest.lazyListKey: String get() = "$paymentRequestId|$counterparty|$counterpartyReceiverPath" + +private val previewRequest = PaykitPaymentRequest( + paymentRequestId = "payment-request", + counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", + counterpartyReceiverPath = "bitkit/wallet", + amountValue = "0.00025", + amountSats = 25_000uL, + note = "Dinner", + createdAt = Instant.parse("2027-01-15T08:00:00Z"), + expiresAt = Instant.parse("2027-01-15T09:00:00Z"), + acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), +) + +@Preview(showSystemUi = true) +@Composable +private fun PaymentRequestsSheetPreview() { + AppThemeSurface { + BottomSheetPreview { + PaymentRequestsSheetContent( + requests = persistentListOf(previewRequest), + contacts = persistentListOf(), + onNotNow = {}, + onSeeAll = {}, + onPay = {}, + onReject = { Result.success(Unit) }, + modifier = Modifier.sheetHeight(), + ) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PaymentRequestsPreview() { + AppThemeSurface { + PaymentRequestsContent( + requests = persistentListOf( + previewRequest, + previewRequest.copy( + paymentRequestId = "sent-payment-request", + direction = PaykitPaymentRequestDirection.Outgoing, + ) + ), + pending = persistentListOf(previewRequest), + contacts = persistentListOf(), + canRequestPayment = true, + onBack = {}, + onRequestPayment = {}, + onPay = {}, + onReject = { Result.success(Unit) }, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index de2f0431c0..cb9fa14ed3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -135,7 +135,6 @@ import to.bitkit.ui.Routes import to.bitkit.ui.components.ActivityBanner import to.bitkit.ui.components.AppStatus import to.bitkit.ui.components.BalanceHeaderView -import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.EmptyStateView import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth @@ -202,7 +201,6 @@ import to.bitkit.viewmodels.WalletViewModel private const val SMALL_SCREEN_HEIGHT_DP = 800 private const val SMALL_SCREEN_SLOT_CAPACITY = 3 private const val LARGE_SCREEN_SLOT_CAPACITY = 4 -private const val MAX_PAYMENT_REQUEST_BADGE_COUNT = 99 private val CALCULATOR_LIFT_SPEC = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow, @@ -241,7 +239,6 @@ fun HomeScreen( val latestActivities by activityListViewModel.latestActivities.collectAsStateWithLifecycle() val hardwareIds by activityListViewModel.hardwareIds.collectAsStateWithLifecycle() val pendingPaymentRequests by appViewModel.pendingPaymentRequests.collectAsStateWithLifecycle() - val sentPaymentRequests by appViewModel.sentPaymentRequests.collectAsStateWithLifecycle() val homeUiState by homeViewModel.uiState.collectAsStateWithLifecycle() @@ -279,18 +276,10 @@ fun HomeScreen( profileDisplayName = profileDisplayName, profileDisplayImageUri = profileDisplayImageUri, showProfileButton = isPaykitEnabled, - showPaymentRequests = isPaykitEnabled && ( - pendingPaymentRequests.isNotEmpty() || sentPaymentRequests.isNotEmpty() - ), + showPaymentRequests = isPaykitEnabled && pendingPaymentRequests.isNotEmpty(), pendingPaymentRequestCount = pendingPaymentRequests.size, onClickProfile = navigateToProfile, - onClickPaymentRequests = { - if (pendingPaymentRequests.isNotEmpty()) { - appViewModel.showPaymentRequests() - } else { - rootNavController.navigateTo(Routes.PaymentRequests) - } - }, + onClickPaymentRequests = appViewModel::showPaymentRequests, latestActivities = latestActivities, hardwareIds = hardwareIds, onRefresh = { @@ -1398,40 +1387,12 @@ private fun TopBar( } .testTag("PaymentRequestsBell"), ) { - Box { - Icon( - painter = painterResource(R.drawable.ic_bell), - tint = Colors.Purple, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - if (pendingPaymentRequestCount > 0) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .align(Alignment.TopEnd) - .size( - if (pendingPaymentRequestCount > MAX_PAYMENT_REQUEST_BADGE_COUNT) { - 22.dp - } else { - 18.dp - } - ) - .clip(CircleShape) - .background(Colors.Purple) - .testTag("PaymentRequestsBadge"), - ) { - BodySSB( - text = if (pendingPaymentRequestCount > MAX_PAYMENT_REQUEST_BADGE_COUNT) { - "$MAX_PAYMENT_REQUEST_BADGE_COUNT+" - } else { - pendingPaymentRequestCount.toString() - }, - color = Colors.Black, - ) - } - } - } + Icon( + painter = painterResource(R.drawable.ic_bell), + tint = Colors.Brand, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) } } IconButton( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 51517f181d..05a9fb24ae 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -55,6 +55,7 @@ import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.UnitButton import to.bitkit.ui.components.VerticalSpacer @@ -78,6 +79,8 @@ fun EditInvoiceScreen( onClickAddTag: () -> Unit, onClickTag: (String) -> Unit, onDescriptionUpdate: (String) -> Unit, + showPaymentRequestButton: Boolean, + onClickPaymentRequest: (amountSats: ULong, note: String) -> Unit, onBack: () -> Unit, navigateReceiveConfirm: (CjitEntryDetails) -> Unit, currencies: CurrencyState = LocalCurrencies.current, @@ -147,6 +150,10 @@ fun EditInvoiceScreen( onClickAddTag = onClickAddTag, onClickTag = onClickTag, isSoftKeyboardVisible = isSoftKeyboardVisible, + showPaymentRequestButton = showPaymentRequestButton, + onClickPaymentRequest = { + onClickPaymentRequest(amountInputUiState.sats.toULong(), walletUiState.bip21Description) + }, ) } @@ -166,6 +173,8 @@ fun EditInvoiceContent( onTextChanged: (String) -> Unit, onClickTag: (String) -> Unit, modifier: Modifier = Modifier, + showPaymentRequestButton: Boolean = false, + onClickPaymentRequest: () -> Unit = {}, isLoading: Boolean = false, currencies: CurrencyState = LocalCurrencies.current, ) { @@ -338,6 +347,15 @@ fun EditInvoiceContent( FillHeight() + if (showPaymentRequestButton) { + SecondaryButton( + text = stringResource(R.string.wallet__payment_request_send), + onClick = onClickPaymentRequest, + modifier = Modifier.testTag("PaymentRequestSendButton"), + ) + VerticalSpacer(12.dp) + } + PrimaryButton( text = stringResource(R.string.wallet__receive_show_qr), onClick = onContinueGeneral, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index ee0a8b45dc..289a6f2587 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -91,8 +91,6 @@ fun ReceiveQrScreen( lightningState: LightningState, onClickEditInvoice: () -> Unit, onClickReceiveCjit: () -> Unit, - showPaymentRequestButton: Boolean = false, - onClickPaymentRequest: () -> Unit = {}, modifier: Modifier = Modifier, initialTab: ReceiveTab? = null, ) { @@ -292,17 +290,6 @@ fun ReceiveQrScreen( VerticalSpacer(24.dp) val showCjitButton = showingCjitOnboarding && selectedTab == ReceiveTab.SPENDING - if (showPaymentRequestButton && !showCjitButton) { - TertiaryButton( - text = stringResource(R.string.wallet__payment_request_send), - onClick = onClickPaymentRequest, - fullWidth = true, - modifier = Modifier - .padding(horizontal = 16.dp) - .testTag("PaymentRequestSendButton"), - ) - VerticalSpacer(12.dp) - } val buttonVariant = when { showCjitButton -> BottomButtonVariant.CJIT showDetails -> BottomButtonVariant.SHOW_QR diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index cfc5a8a8d5..d97c72887b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -72,8 +72,8 @@ fun ReceiveSheet( var paymentRequestDraft by remember { mutableStateOf( PaykitPaymentRequestDraft( - amountSats = walletState.bip21AmountSats ?: 0uL, - note = walletState.bip21Description, + amountSats = 0uL, + note = "", expiresAt = Clock.System.now() + 7.days, ) ) @@ -124,15 +124,6 @@ fun ReceiveSheet( } }, onClickEditInvoice = { navController.navigateTo(ReceiveRoute.EditInvoice) }, - showPaymentRequestButton = paymentRequestTargets.isNotEmpty(), - onClickPaymentRequest = { - paymentRequestDraft = PaykitPaymentRequestDraft( - amountSats = walletState.bip21AmountSats ?: 0uL, - note = walletState.bip21Description, - expiresAt = Clock.System.now() + 7.days, - ) - navController.navigateTo(ReceiveRoute.PaymentRequestDetails) - }, ) } composableWithDefaultTransitions { @@ -160,6 +151,7 @@ fun ReceiveSheet( composableWithDefaultTransitions { createdPaymentRequest?.let { PaymentRequestSentScreen( + appViewModel = appViewModel, request = it, onDone = appViewModel::hideSheet, ) @@ -261,6 +253,15 @@ fun ReceiveSheet( onClickAddTag = { navController.navigateTo(ReceiveRoute.AddTag) }, onClickTag = wallet::removeTag, onDescriptionUpdate = wallet::updateBip21Description, + showPaymentRequestButton = paymentRequestTargets.isNotEmpty(), + onClickPaymentRequest = { amountSats, note -> + paymentRequestDraft = PaykitPaymentRequestDraft( + amountSats = amountSats, + note = note, + expiresAt = Clock.System.now() + 7.days, + ) + navController.navigateTo(ReceiveRoute.PaymentRequestDetails) + }, navigateReceiveConfirm = { entry -> cjitEntryDetails.value = entry navController.navigateTo(ReceiveRoute.ConfirmIncreaseInbound) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt index b159762811..1dc7352738 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectScreen.kt @@ -1,11 +1,8 @@ package to.bitkit.ui.screens.wallets.send -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -18,7 +15,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -28,12 +24,9 @@ import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.BodyS -import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.GradientCircularProgressIndicator -import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.PubkyContactRow import to.bitkit.ui.scaffold.SheetTopBar -import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -123,7 +116,7 @@ private fun SendContactList( .padding(horizontal = 16.dp) ) { items(contacts, key = { it.publicKey }) { contact -> - ContactRow( + PubkyContactRow( profile = contact, onClick = { onContactClick(contact.publicKey) }, modifier = Modifier.testTag("SendContact_${contact.publicKey}") @@ -133,40 +126,6 @@ private fun SendContactList( } } -@Composable -private fun ContactRow( - profile: PubkyProfile, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = modifier - .fillMaxWidth() - .clickableAlpha(onClick = onClick) - .padding(vertical = 12.dp) - ) { - PubkyContactAvatar(profile = profile) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.weight(1f) - ) { - BodyS( - text = profile.truncatedPublicKey, - color = Colors.White64, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BodySSB( - text = profile.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - @Preview @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b381e4b70e..5a837f3e9e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -292,7 +292,7 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() val pendingPaymentRequests = paykitPaymentRequestRepo.pendingRequests - val sentPaymentRequests = paykitPaymentRequestRepo.sentRequests + val paymentRequestHistory = paykitPaymentRequestRepo.paymentRequestHistory val eligiblePaymentRequestTargets = paykitPaymentRequestRepo.eligibleTargets val isCreatingPaymentRequest = paykitPaymentRequestRepo.isCreatingRequest val pubkyContacts = pubkyRepo.contacts diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98d83ffa7a..5b5536f9c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1168,35 +1168,50 @@ The recipient rejected this Lightning invoice request. The recipient rejected this Lightning payment. Please check the invoice and try again. Payment Request - The payment details did not match the request. Payment cancelled. - Payment Requests - Review requests you have received or sent. - INCOMING - SENT - No payment requests yet. - Not Now - See All - %1$d pending payment requests - Reject - Pay - Waiting for payment - Waiting for updated private payment details. Bitkit will retry automatically. - Queued for delivery - Send Payment Request Amount - Note - What is this payment for? + Choose Recipient + %1$s - %2$s + Dismiss + Enter pubky Expires in - 1 hour 1 day - 1 week + 1 hour 1 month - Choose Recipient + 1 week + The payment details did not match the request. Payment cancelled. + Note + What is this payment for? + Pay + Your payment request is queued and will send automatically + RECIPIENT + Request Payment + Send Payment Request Send Request - Sent - Requested]]> You have sent a payment request - Your payment request is queued and will send automatically + Requested]]> + Sent + Queued for delivery + Accepted + Action required + Canceled + Expired + Proof submitted + Rejected + Unavailable + %1$s at %2$s + Waiting for payment + Waiting for %1$s to pay + Waiting for updated private payment details. Bitkit will retry automatically. + Payment Requests + No payment requests yet. + Earlier + %1$s %2$d + Not Now + %1$d pending payment requests + Review each request, then pay or dismiss. + PAYMENT REQUESTS + See All + This Month Bitkit tried several Lightning routes, but the payment could not be completed. Bitkit couldn\'t find a Lightning route for this payment. Payment timed out. Please try again. diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 6a10dd2658..6b8d661759 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -79,7 +79,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { schedulerOriginMillis = testDispatcher.scheduler.currentTime whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) whenever(paykitSdkService.receivePrivateMessagesFromLinkedPeers()).thenReturn(emptyList()) - whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(emptyList()) whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) whenever(settingsStore.isPaykitEnabled).thenReturn(flowOf(true)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData(sharesPrivatePaykitEndpoints = true))) @@ -96,7 +95,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh maps actionable bitcoin request`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(60.seconds).toString()) - whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) sut.refresh(emptyList()).getOrThrow() @@ -107,7 +106,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh rejects amounts outside the app payment range`() = test { - whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn( + whenever(paykitSdkService.paymentRequests()).thenReturn( listOf( paymentRequestRecord(id = "millisatoshi-safe-max", amount = "184467440.73709551"), paymentRequestRecord(id = "millisatoshi-overflow", amount = "184467440.73709552"), @@ -143,7 +142,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `refresh drops expired unsupported and non payer requests`() = test { - whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn( + whenever(paykitSdkService.paymentRequests()).thenReturn( listOf( paymentRequestRecord(expiresAt = clock.now().toString()), paymentRequestRecord(id = "unsupported", endpoints = listOf("btc-unsupported-method")), @@ -156,9 +155,44 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.pendingRequests.value.isEmpty()) } + @Test + fun `refresh keeps one time bitcoin lifecycle history`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord(id = "incoming"), + paymentRequestRecord(id = "accepted", state = PaymentRequestLifecycleState.ACCEPTED), + paymentRequestRecord(id = "rejected", state = PaymentRequestLifecycleState.REJECTED), + paymentRequestRecord( + id = "expired", + state = PaymentRequestLifecycleState.PROPOSAL_EXPIRED, + expiresAt = clock.now().toString(), + ), + paymentRequestRecord(id = "outgoing", role = PaymentRequestLocalRole.PAYEE), + paymentRequestRecord(id = "unsupported", endpoints = listOf("btc-unsupported-method")), + paymentRequestRecord(id = "recurring", state = PaymentRequestLifecycleState.ACTIVE_RECURRING), + ), + ) + + sut.refresh(emptyList()).getOrThrow() + + assertEquals(listOf("incoming"), sut.pendingRequests.value.map { it.paymentRequestId }) + assertEquals( + setOf("incoming", "accepted", "rejected", "expired", "outgoing", "unsupported"), + sut.paymentRequestHistory.value.map { it.paymentRequestId }.toSet(), + ) + assertEquals( + PaymentRequestLifecycleState.ACCEPTED, + sut.paymentRequestHistory.value.first { it.paymentRequestId == "accepted" }.lifecycleState, + ) + assertEquals( + PaykitPaymentRequestDirection.Outgoing, + sut.paymentRequestHistory.value.first { it.paymentRequestId == "outgoing" }.direction, + ) + } + @Test fun `pending request is removed exactly when it expires`() = test { - whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn( + whenever(paykitSdkService.paymentRequests()).thenReturn( listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), ) sut.refresh(emptyList()).getOrThrow() @@ -170,12 +204,40 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(1) runCurrent() assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals( + PaymentRequestLifecycleState.PROPOSAL_EXPIRED, + sut.paymentRequestHistory.value.single().lifecycleState, + ) + } + + @Test + fun `outgoing request moves to expired history exactly when it expires`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord( + role = PaymentRequestLocalRole.PAYEE, + expiresAt = clock.now().plus(10.seconds).toString(), + ), + ), + ) + sut.refresh(emptyList()).getOrThrow() + + advanceTimeBy(9_999) + runCurrent() + assertEquals(PaymentRequestLifecycleState.PROPOSED, sut.paymentRequestHistory.value.single().lifecycleState) + + advanceTimeBy(1) + runCurrent() + assertEquals( + PaymentRequestLifecycleState.PROPOSAL_EXPIRED, + sut.paymentRequestHistory.value.single().lifecycleState, + ) } @Test fun `accept removes current request and delivers queued response`() = test { val record = paymentRequestRecord() - whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( paykitSdkService.acceptPaymentRequest( COUNTERPARTY, @@ -189,13 +251,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.accept(sut.pendingRequests.value.single()).getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(PaymentRequestLifecycleState.ACCEPTED, sut.paymentRequestHistory.value.single().lifecycleState) verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } } @Test fun `reject removes current request and delivers queued response`() = test { val record = paymentRequestRecord() - whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever( paykitSdkService.rejectPaymentRequest( COUNTERPARTY, @@ -209,12 +272,13 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.reject(sut.pendingRequests.value.single()).getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(PaymentRequestLifecycleState.REJECTED, sut.paymentRequestHistory.value.single().lifecycleState) verifyBlocking(paykitSdkService) { processPendingPrivateMessages() } } @Test fun `surfaced request stays pending and is excluded from automatic presentation`() = test { - whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) sut.refresh(emptyList()).getOrThrow() val request = sut.pendingRequests.value.single() @@ -227,7 +291,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `switching identity clears request state and restores only that identity suppression`() = test { - whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) sut.refresh(emptyList()).getOrThrow() val request = sut.pendingRequests.value.single() sut.markPresented(request) @@ -236,7 +300,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.activate(SECOND_IDENTITY) assertTrue(sut.pendingRequests.value.isEmpty()) - assertTrue(sut.sentRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) assertTrue(sut.eligibleTargets.value.isEmpty()) } @@ -249,7 +313,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { resumeRefresh.await() emptyList() } - whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) whenever(presentationStore.load(SECOND_IDENTITY)).thenReturn(emptySet()) val refresh = async { sut.refresh(emptyList()) } @@ -263,7 +327,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { activation.await() assertTrue(sut.pendingRequests.value.isEmpty()) - assertTrue(sut.sentRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) assertTrue(sut.eligibleTargets.value.isEmpty()) } @@ -356,7 +420,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(LOCAL_IDENTITY, creation.creatorIdentity) assertFalse(creation.wasPublishedToActiveState) - assertTrue(sut.sentRequests.value.isEmpty()) + assertTrue(sut.paymentRequestHistory.value.isEmpty()) } @Test @@ -409,7 +473,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `expired request cannot be accepted`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) - whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) sut.refresh(emptyList()).getOrThrow() val request = sut.pendingRequests.value.single() advanceTimeBy(1_000) @@ -425,7 +489,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `expired request is no longer pending before the expiration job runs`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(1.seconds).toString()) - whenever(paykitSdkService.actionableReceivedPaymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) sut.refresh(emptyList()).getOrThrow() val request = sut.pendingRequests.value.single() advanceTimeBy(1_000) @@ -437,6 +501,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { private fun paymentRequestRecord( id: String = PAYMENT_REQUEST_ID, role: PaymentRequestLocalRole? = PaymentRequestLocalRole.PAYER, + state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, amount: String = "0.001", expiresAt: String? = null, endpoints: List = listOf(MethodId.Bolt11.rawValue), @@ -447,7 +512,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { counterpartyReceiverPath = receiverPath, paymentRequestId = id, localRole = role, - state = PaymentRequestLifecycleState.PROPOSED, + state = state, proposalStreamItemId = 1uL, proposalOutboundMessageId = null, proposalOutboundStatus = null, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index e998a49b97..a8d6d5218e 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -189,7 +189,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val pubkyContacts = MutableStateFlow>(emptyList()) private val pubkyContactsLoadVersion = MutableStateFlow(0L) private val pendingPaykitPaymentRequests = MutableStateFlow>(emptyList()) - private val sentPaykitPaymentRequests = MutableStateFlow>(emptyList()) + private val paykitPaymentRequestHistory = MutableStateFlow>(emptyList()) private val surfacedPaykitPaymentRequestIds = mutableSetOf() private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -199,7 +199,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Before fun setUp() { timedSheetType.value = null - sentPaykitPaymentRequests.value = emptyList() + paykitPaymentRequestHistory.value = emptyList() surfacedPaykitPaymentRequestIds.clear() stubRepositories() sut = createViewModel() @@ -249,7 +249,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) - whenever(paykitPaymentRequestRepo.sentRequests).thenReturn(sentPaykitPaymentRequests) + whenever(paykitPaymentRequestRepo.paymentRequestHistory).thenReturn(paykitPaymentRequestHistory) whenever(paykitPaymentRequestRepo.eligibleTargets).thenReturn(MutableStateFlow(emptyList())) whenever(paykitPaymentRequestRepo.isCreatingRequest).thenReturn(MutableStateFlow(false)) whenever(paykitPaymentRequestRepo.automaticPendingRequests()).thenAnswer { diff --git a/changelog.d/next/1172.added.md b/changelog.d/next/1172.added.md index c95957385d..bffb6f1b47 100644 --- a/changelog.d/next/1172.added.md +++ b/changelog.d/next/1172.added.md @@ -1 +1 @@ -Added private Paykit payment requests with automatic incoming prompts, a persistent request queue, and contact-based request creation. +Incoming Paykit payment requests now stay discoverable until handled, and users can send new private requests to connected contacts from the invoice flow. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ad9ccae8d9..f25a4babeb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.5" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc44" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc46" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From c74fc0d1e58ec06dfb991a74a98d717a4cef55fd Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 20 Aug 2026 17:43:52 -0500 Subject: [PATCH 5/8] fix: align payment request history UI --- .../ui/components/DrawerMenuWidgetsTest.kt | 59 +++++++++ .../PaymentRequestsScreenTest.kt | 63 ++++++++- app/src/main/java/to/bitkit/ui/ContentView.kt | 16 ++- .../to/bitkit/ui/components/DrawerMenu.kt | 15 +++ .../java/to/bitkit/ui/components/Money.kt | 27 ++++ .../paymentrequests/PaymentRequestsScreen.kt | 125 +++++++++++------- app/src/main/res/values/strings.xml | 4 +- 7 files changed, 248 insertions(+), 61 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt index ca1c0fc1a8..ee7570a3c0 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -150,6 +151,64 @@ class DrawerMenuWidgetsTest { composeTestRule.onNodeWithTag("WidgetsSheetRequested").assertIsDisplayed() } + + @Test + fun paymentRequestsIsAvailableFromDrawerWhenPaykitIsEnabled() { + composeTestRule.setContent { + val navController = rememberNavController() + val drawerState = rememberDrawerState(DrawerValue.Open) + + DrawerMenuTestSurface { + NavHost( + navController = navController, + startDestination = Routes.Home, + ) { + composable { + Text("Home", modifier = Modifier.testTag("HomeRoute")) + } + composable { + Text("Payment Requests", modifier = Modifier.testTag("PaymentRequestsRoute")) + } + } + DrawerMenu( + drawerState = drawerState, + rootNavController = navController, + hasSeenWidgetsIntro = true, + hasSeenShopIntro = true, + onBeforeNavigate = {}, + showWidgets = true, + isPaykitEnabled = true, + ) + } + } + + composeTestRule.onNodeWithText("REQUESTS").assertIsDisplayed() + composeTestRule.onNodeWithTag("DrawerPaymentRequests").performClick() + + composeTestRule.onNodeWithTag("PaymentRequestsRoute").assertIsDisplayed() + } + + @Test + fun paymentRequestsIsHiddenFromDrawerWhenPaykitIsDisabled() { + composeTestRule.setContent { + val navController = rememberNavController() + val drawerState = rememberDrawerState(DrawerValue.Open) + + DrawerMenuTestSurface { + DrawerMenu( + drawerState = drawerState, + rootNavController = navController, + hasSeenWidgetsIntro = true, + hasSeenShopIntro = true, + onBeforeNavigate = {}, + showWidgets = true, + isPaykitEnabled = false, + ) + } + } + + composeTestRule.onNodeWithTag("DrawerPaymentRequests").assertDoesNotExist() + } } @Composable diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index b5bacf7150..38b3ff49ec 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -2,6 +2,9 @@ package to.bitkit.ui.screens.paymentrequests +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -29,7 +32,7 @@ class PaymentRequestsScreenTest { val request = request(id = "incoming") composeTestRule.setContent { - AppThemeSurface { + PaymentRequestsTestSurface { PaymentRequestsSheetContent( requests = persistentListOf(request), contacts = persistentListOf(), @@ -42,6 +45,8 @@ class PaymentRequestsScreenTest { } composeTestRule.onNodeWithTag("PaymentRequestRowincoming").assertIsDisplayed() + composeTestRule.onNodeWithTag("MoneyPrimary").assertIsDisplayed() + composeTestRule.onNodeWithTag("MoneySecondary").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestsSeeAll").assertIsDisplayed() composeTestRule.onNodeWithText("Dismiss").assertIsDisplayed() } @@ -59,7 +64,7 @@ class PaymentRequestsScreenTest { ) composeTestRule.setContent { - AppThemeSurface { + PaymentRequestsTestSurface { PaymentRequestsContent( requests = persistentListOf(outgoing, accepted), pending = persistentListOf(), @@ -81,6 +86,51 @@ class PaymentRequestsScreenTest { composeTestRule.onNodeWithTag("PaymentRequestCreate").assertIsDisplayed() } + @Test + fun emptyHistoryMatchesPaymentRequestsEmptyState() { + composeTestRule.setContent { + PaymentRequestsTestSurface { + PaymentRequestsContent( + requests = persistentListOf(), + pending = persistentListOf(), + contacts = persistentListOf(), + canRequestPayment = true, + onBack = {}, + onRequestPayment = {}, + onPay = {}, + onReject = { Result.success(Unit) }, + ) + } + } + + composeTestRule.onNodeWithText("NO PAYMENT\nHISTORY").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestsEmptyIllustration").assertIsDisplayed() + composeTestRule.onNodeWithText( + "You have not made any payments to providers and don’t have any payment requests yet." + ).assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestCreate").assertIsDisplayed() + } + + @Test + fun requestPaymentIsHiddenWithoutEligibleRecipients() { + composeTestRule.setContent { + PaymentRequestsTestSurface { + PaymentRequestsContent( + requests = persistentListOf(), + pending = persistentListOf(), + contacts = persistentListOf(), + canRequestPayment = false, + onBack = {}, + onRequestPayment = {}, + onPay = {}, + onReject = { Result.success(Unit) }, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestCreate").assertDoesNotExist() + } + private fun request(id: String) = PaykitPaymentRequest( paymentRequestId = id, counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", @@ -93,3 +143,12 @@ class PaymentRequestsScreenTest { acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), ) } + +@Composable +private fun PaymentRequestsTestSurface(content: @Composable () -> Unit) { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + content() + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index f6040b8ae1..a8222c8085 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -728,13 +728,15 @@ private fun RootNavHost( navController = navController, ) composableWithDefaultTransitions { - PaymentRequestsScreen( - appViewModel = appViewModel, - onBack = { navController.popBackStack() }, - onRequestPayment = { - appViewModel.showSheet(Sheet.Receive(route = ReceiveRoute.PaymentRequestDetails)) - }, - ) + PaykitRouteGuard(settingsViewModel, navController) { + PaymentRequestsScreen( + appViewModel = appViewModel, + onBack = { navController.popBackStack() }, + onRequestPayment = { + appViewModel.showSheet(Sheet.Receive(route = ReceiveRoute.PaymentRequestDetails)) + }, + ) + } } settings(navController, settingsViewModel) contacts(navController, settingsViewModel, appViewModel) diff --git a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt index ef29477208..a0dc4053d6 100644 --- a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt +++ b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt @@ -185,6 +185,7 @@ fun DrawerMenu( onBeforeNavigate(Routes.Home) onOpenWalletHome() }, + showPaymentRequests = isPaykitEnabled, onBeforeNavigate = onBeforeNavigate, ) } @@ -199,6 +200,7 @@ private fun Menu( onClickContacts: () -> Unit, onClickProfile: () -> Unit, onClickWallet: () -> Unit, + showPaymentRequests: Boolean, onBeforeNavigate: (Routes?) -> Unit, ) { val scope = rememberCoroutineScope() @@ -233,6 +235,19 @@ private fun Menu( modifier = Modifier.testTag("DrawerActivity") ) + if (showPaymentRequests) { + DrawerItem( + label = stringResource(R.string.wallet__drawer__payment_requests), + iconRes = R.drawable.ic_file_text, + onClick = { + onBeforeNavigate(Routes.PaymentRequests) + rootNavController.navigateIfNotCurrent(Routes.PaymentRequests) + scope.launch { drawerState.close() } + }, + modifier = Modifier.testTag("DrawerPaymentRequests") + ) + } + DrawerItem( label = stringResource(R.string.wallet__drawer__contacts), iconRes = R.drawable.ic_users, 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 2358169182..6aa6ba909b 100644 --- a/app/src/main/java/to/bitkit/ui/components/Money.kt +++ b/app/src/main/java/to/bitkit/ui/components/Money.kt @@ -64,6 +64,33 @@ fun MoneyStack( } } +@Composable +fun MoneyCell( + sats: Long, + modifier: Modifier = Modifier, +) { + val currencies = LocalCurrencies.current + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = modifier, + ) { + rememberMoneyText(sats = sats, unit = currencies.primaryDisplay, showSymbol = true)?.let { text -> + BodyMSB( + text = text.withAccent(accentColor = Colors.White64), + modifier = Modifier.testTag("MoneyPrimary"), + ) + } + rememberMoneyText(sats = sats, unit = currencies.primaryDisplay.not(), showSymbol = true)?.let { text -> + CaptionB( + text = text.withAccent(accentColor = Colors.White64), + color = Colors.White64, + modifier = Modifier.testTag("MoneySecondary"), + ) + } + } +} + @Composable fun MoneySSB( sats: Long, diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index f89e80511d..b182e53d6c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -2,9 +2,11 @@ package to.bitkit.ui.screens.paymentrequests +import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -53,7 +55,9 @@ import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up -import to.bitkit.ui.components.MoneySSB +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.MoneyCell import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactAvatar import to.bitkit.ui.components.SecondaryButton @@ -67,6 +71,7 @@ import to.bitkit.ui.shared.util.outerGlow import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.uiDateText +import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.AppViewModel import java.time.YearMonth import java.time.ZoneId @@ -112,7 +117,8 @@ internal fun PaymentRequestsSheetContent( ) { Column( modifier = modifier - .fillMaxSize() + .fillMaxWidth() + .sheetHeight() .gradientBackground() .navigationBarsPadding() .padding(horizontal = 16.dp) @@ -206,56 +212,75 @@ internal fun PaymentRequestsContent( onBackClick = onBack, actions = { DrawerNavIcon() }, ) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp), - ) { - if (requests.isEmpty()) { - item { - BodyM( - text = stringResource(R.string.wallet__payment_requests_empty), - color = Colors.White64, - modifier = Modifier - .fillMaxWidth() - .padding(top = 120.dp), - ) - } - } - if (sections.active.isNotEmpty()) { - item { - Caption13Up( - text = stringResource(R.string.wallet__payment_requests_section), - color = Colors.White64, - ) - } - items(sections.active, key = { it.lazyListKey }) { request -> - ActivePaymentRequestCard( - request = request, - isIncoming = pending.any { it.id == request.id }, - contact = contacts.contactFor(request), - onPay = onPay, - onReject = onReject, - ) - } + if (requests.isEmpty()) { + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + ) { + FillHeight() + Image( + painter = painterResource(R.drawable.restore), + contentDescription = null, + modifier = Modifier + .size(256.dp) + .align(Alignment.CenterHorizontally) + .testTag("PaymentRequestsEmptyIllustration"), + ) + FillHeight() + Display( + text = stringResource(R.string.wallet__payment_requests_empty_headline) + .withAccent(accentColor = Colors.Purple), + ) + VerticalSpacer(12.dp) + BodyM( + text = stringResource(R.string.wallet__payment_requests_empty_description), + color = Colors.White64, + ) + VerticalSpacer(24.dp) } - sections.historyByMonth.forEach { (month, history) -> - item(key = "history-${month ?: "earlier"}") { - Caption13Up( - text = paymentRequestHistorySectionTitle(month, currentMonth), - color = Colors.White64, - ) + } else { + LazyColumn( + contentPadding = PaddingValues(top = 24.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + ) { + if (sections.active.isNotEmpty()) { + item { + Caption13Up( + text = stringResource(R.string.wallet__payment_requests_section), + color = Colors.White64, + ) + } + items(sections.active, key = { it.lazyListKey }) { request -> + ActivePaymentRequestCard( + request = request, + isIncoming = pending.any { it.id == request.id }, + contact = contacts.contactFor(request), + onPay = onPay, + onReject = onReject, + ) + } } - items(history, key = { it.lazyListKey }) { request -> - PaymentRequestCard( - request = request, - contact = contacts.contactFor(request), - compactSubtitle = paymentRequestDate(request), - ) + sections.historyByMonth.forEach { (month, history) -> + item(key = "history-${month ?: "earlier"}") { + Caption13Up( + text = paymentRequestHistorySectionTitle(month, currentMonth), + color = Colors.White64, + ) + } + items(history, key = { it.lazyListKey }) { request -> + PaymentRequestCard( + request = request, + contact = contacts.contactFor(request), + compactSubtitle = paymentRequestDate(request), + ) + } } + item { VerticalSpacer(8.dp) } } - item { VerticalSpacer(8.dp) } } if (canRequestPayment) { PrimaryButton( @@ -453,9 +478,8 @@ internal fun PaymentRequestCard( overflow = TextOverflow.Ellipsis, ) } - MoneySSB( + MoneyCell( sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), - showSymbol = true, ) } if (onPay != null || onReject != null) { @@ -535,7 +559,6 @@ private fun PaymentRequestsSheetPreview() { onSeeAll = {}, onPay = {}, onReject = { Result.success(Unit) }, - modifier = Modifier.sheetHeight(), ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b5536f9c5..5d3065e67e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1125,6 +1125,7 @@ Incoming Transfer: Activity Contacts + Requests Profile Settings Shop @@ -1203,7 +1204,8 @@ Waiting for %1$s to pay Waiting for updated private payment details. Bitkit will retry automatically. Payment Requests - No payment requests yet. + You have not made any payments to providers and don’t have any payment requests yet. + History]]> Earlier %1$s %2$d Not Now From e1fee9cfff300be330f583cc1d53f961238347a5 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 20 Aug 2026 20:11:15 -0500 Subject: [PATCH 6/8] fix: group payment request history by recency --- .../PaymentRequestsScreenTest.kt | 7 +- .../paymentrequests/PaymentRequestsScreen.kt | 84 ++++++++++++------- app/src/main/res/values/strings.xml | 5 +- 3 files changed, 63 insertions(+), 33 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index 38b3ff49ec..436b703804 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -53,12 +53,13 @@ class PaymentRequestsScreenTest { @Test fun historyGroupsCompletedRequestsAndKeepsActiveOutgoingRequests() { + val now = Clock.System.now() val accepted = request(id = "accepted").copy( - createdAt = Clock.System.now(), + createdAt = now, lifecycleState = PaymentRequestLifecycleState.ACCEPTED, ) val outgoing = request(id = "outgoing").copy( - createdAt = Clock.System.now(), + createdAt = now, direction = PaykitPaymentRequestDirection.Outgoing, deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent, ) @@ -82,7 +83,7 @@ class PaymentRequestsScreenTest { composeTestRule.onNodeWithTag("PaymentRequestRowoutgoing").assertIsDisplayed() composeTestRule.onNodeWithText("Waiting for", substring = true).assertIsDisplayed() composeTestRule.onNodeWithText("PAYMENT REQUESTS").assertIsDisplayed() - composeTestRule.onNodeWithText("THIS MONTH").assertIsDisplayed() + composeTestRule.onNodeWithText("TODAY").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestCreate").assertIsDisplayed() } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index b182e53d6c..df2a9dbfe6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -73,9 +73,9 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.uiDateText import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.AppViewModel -import java.time.YearMonth import java.time.ZoneId -import java.time.format.TextStyle +import java.time.temporal.TemporalAdjusters +import java.time.temporal.WeekFields import java.util.Locale import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -198,7 +198,6 @@ internal fun PaymentRequestsContent( onReject: suspend (PaykitPaymentRequest) -> Result, ) { val sections = paymentRequestSections(requests, pending, Clock.System.now()) - val currentMonth = YearMonth.now() Column( modifier = modifier @@ -264,14 +263,14 @@ internal fun PaymentRequestsContent( ) } } - sections.historyByMonth.forEach { (month, history) -> - item(key = "history-${month ?: "earlier"}") { + sections.history.forEach { section -> + item(key = "history-${section.period.name}") { Caption13Up( - text = paymentRequestHistorySectionTitle(month, currentMonth), + text = paymentRequestHistorySectionTitle(section.period), color = Colors.White64, ) } - items(history, key = { it.lazyListKey }) { request -> + items(section.requests, key = { it.lazyListKey }) { request -> PaymentRequestCard( request = request, contact = contacts.contactFor(request), @@ -297,9 +296,23 @@ internal fun PaymentRequestsContent( private data class PaymentRequestSections( val active: List, - val historyByMonth: Map>, + val history: List, ) +private data class PaymentRequestHistorySection( + val period: PaymentRequestHistoryPeriod, + val requests: List, +) + +private enum class PaymentRequestHistoryPeriod { + Today, + Yesterday, + ThisWeek, + ThisMonth, + ThisYear, + Earlier, +} + private fun paymentRequestSections( requests: List, pending: List, @@ -313,8 +326,14 @@ private fun paymentRequestSections( !request.isExpired(now) } val activeIds = active.mapTo(mutableSetOf()) { it.id } - val historyByMonth = requests.filterNot { it.id in activeIds }.groupBy { it.historyMonth() } - return PaymentRequestSections(active, historyByMonth) + val groupedHistory = requests + .filterNot { it.id in activeIds } + .sortedWith { first, second -> compareValues(second.createdAt, first.createdAt) } + .groupBy { it.historyPeriod(now) } + val history = PaymentRequestHistoryPeriod.entries.mapNotNull { period -> + groupedHistory[period]?.let { PaymentRequestHistorySection(period, it) } + } + return PaymentRequestSections(active, history) } @Composable @@ -346,14 +365,34 @@ private fun ActivePaymentRequestCard( } @Composable -private fun paymentRequestHistorySectionTitle(month: YearMonth?, currentMonth: YearMonth): String = when { - month == currentMonth -> stringResource(R.string.wallet__payment_requests_this_month) - month != null -> month.sectionTitle(currentMonth) - else -> stringResource(R.string.wallet__payment_requests_earlier) +private fun paymentRequestHistorySectionTitle(period: PaymentRequestHistoryPeriod): String = when (period) { + PaymentRequestHistoryPeriod.Today -> stringResource(R.string.wallet__payment_requests_today) + PaymentRequestHistoryPeriod.Yesterday -> stringResource(R.string.wallet__payment_requests_yesterday) + PaymentRequestHistoryPeriod.ThisWeek -> stringResource(R.string.wallet__payment_requests_this_week) + PaymentRequestHistoryPeriod.ThisMonth -> stringResource(R.string.wallet__payment_requests_this_month) + PaymentRequestHistoryPeriod.ThisYear -> stringResource(R.string.wallet__payment_requests_this_year) + PaymentRequestHistoryPeriod.Earlier -> stringResource(R.string.wallet__payment_requests_earlier) } -private fun PaykitPaymentRequest.historyMonth(): YearMonth? = createdAt?.let { - YearMonth.from(JavaInstant.ofEpochMilli(it.toEpochMilliseconds()).atZone(ZoneId.systemDefault())) +private fun PaykitPaymentRequest.historyPeriod( + now: Instant, + zoneId: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): PaymentRequestHistoryPeriod { + val date = createdAt?.let { + JavaInstant.ofEpochMilli(it.toEpochMilliseconds()).atZone(zoneId).toLocalDate() + } ?: return PaymentRequestHistoryPeriod.Earlier + val today = JavaInstant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zoneId).toLocalDate() + val startOfWeek = today.with(TemporalAdjusters.previousOrSame(WeekFields.of(locale).firstDayOfWeek)) + + return when { + date == today -> PaymentRequestHistoryPeriod.Today + date == today.minusDays(1) -> PaymentRequestHistoryPeriod.Yesterday + !date.isBefore(startOfWeek) -> PaymentRequestHistoryPeriod.ThisWeek + date.year == today.year && date.month == today.month -> PaymentRequestHistoryPeriod.ThisMonth + date.year == today.year -> PaymentRequestHistoryPeriod.ThisYear + else -> PaymentRequestHistoryPeriod.Earlier + } } @Composable @@ -371,19 +410,6 @@ private fun paymentRequestDateTime(request: PaykitPaymentRequest): String = requ ) } ?: paymentRequestStatus(request) -@Composable -private fun YearMonth.sectionTitle( - currentMonth: YearMonth, - locale: Locale = Locale.getDefault(), -): String { - val monthName = month.getDisplayName(TextStyle.FULL, locale) - return if (year == currentMonth.year) { - monthName - } else { - stringResource(R.string.wallet__payment_requests_month_year, monthName, year) - } -} - @Composable private fun paymentRequestStatus(request: PaykitPaymentRequest): String { if (request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && request.isExpired(Clock.System.now())) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d3065e67e..be0e8f091d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1207,13 +1207,16 @@ You have not made any payments to providers and don’t have any payment requests yet. History]]> Earlier - %1$s %2$d Not Now %1$d pending payment requests Review each request, then pay or dismiss. PAYMENT REQUESTS See All This Month + This Week + This Year + Today + Yesterday Bitkit tried several Lightning routes, but the payment could not be completed. Bitkit couldn\'t find a Lightning route for this payment. Payment timed out. Please try again. From 7e6a3162921cf55c58ba1ddffb2860e1dd9b09eb Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 21 Aug 2026 09:57:46 -0500 Subject: [PATCH 7/8] fix: streamline payment request recipient flow --- .../CreatePaymentRequestScreenTest.kt | 5 +- .../bitkit/ui/components/PubkyContactRow.kt | 4 +- .../java/to/bitkit/ui/scaffold/SheetTopBar.kt | 11 ++++ .../CreatePaymentRequestScreen.kt | 57 ++++++++++++++++--- .../paymentrequests/PaymentRequestsScreen.kt | 8 ++- .../screens/wallets/receive/ReceiveSheet.kt | 20 ++++++- app/src/main/res/values/strings.xml | 2 + 7 files changed, 93 insertions(+), 14 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt index 7c0e948049..f5fb721010 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt @@ -61,7 +61,8 @@ class CreatePaymentRequestScreenTest { targets = persistentListOf(target), contacts = persistentListOf(PubkyProfile.placeholder(target.publicKey)), isCreating = false, - onBack = {}, + onEditExpiration = {}, + onPaste = { target.publicKey }, onSend = {}, ) } @@ -69,6 +70,8 @@ class CreatePaymentRequestScreenTest { composeTestRule.onNodeWithTag("PaymentRequestContact${target.publicKey}").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestRecipientSearch").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestEditExpiration").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestRecipientPaste").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestSend").assertIsDisplayed() composeTestRule.onNodeWithTag("PaymentRequestRecipientSearch").performTextInput("not this contact") 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 046439b76a..f2e837add0 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.disabled @@ -32,6 +33,7 @@ fun PubkyContactRow( isSelected: Boolean? = null, isEnabled: Boolean = true, verticalPadding: Dp = 12.dp, + selectionColor: Color = Colors.PubkyGreen, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -73,7 +75,7 @@ fun PubkyContactRow( Icon( painter = painterResource(R.drawable.ic_check), contentDescription = null, - tint = Colors.PubkyGreen, + tint = selectionColor, modifier = Modifier.size(24.dp) ) } diff --git a/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt b/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt index 5f5bc53989..3b26eb36ca 100644 --- a/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt +++ b/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt @@ -25,6 +25,7 @@ fun SheetTopBar( titleText: String?, modifier: Modifier = Modifier, onBack: (() -> Unit)? = null, + action: (@Composable () -> Unit)? = null, ) { Box( modifier = modifier @@ -50,6 +51,16 @@ fun SheetTopBar( .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Horizontal)) ) } + action?.let { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.CenterEnd) + .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Horizontal)) + ) { + it() + } + } } } 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 4007f0aaed..94e1d431a1 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 @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -41,6 +42,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import to.bitkit.R +import to.bitkit.ext.getClipboardText import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.AmountInputHandler @@ -50,6 +52,7 @@ import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up @@ -59,13 +62,13 @@ import to.bitkit.ui.components.NumberPad import to.bitkit.ui.components.NumberPadTextField import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactRow -import to.bitkit.ui.components.SearchInput import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent @@ -249,9 +252,10 @@ internal fun PaymentRequestDetailsContent( fun PaymentRequestRecipientScreen( appViewModel: AppViewModel, draft: PaykitPaymentRequestDraft, - onBack: () -> Unit, + onEditExpiration: () -> Unit, onSent: (PaykitPaymentRequest) -> Unit, ) { + val context = LocalContext.current val targets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() val isCreating by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() @@ -260,7 +264,8 @@ fun PaymentRequestRecipientScreen( targets = targets.toImmutableList(), contacts = contacts.toImmutableList(), isCreating = isCreating, - onBack = onBack, + onEditExpiration = onEditExpiration, + onPaste = { context.getClipboardText()?.trim().orEmpty() }, onSend = { target -> appViewModel.createPaymentRequest(draft, target, onSent) }, ) } @@ -271,7 +276,8 @@ internal fun PaymentRequestRecipientContent( targets: ImmutableList, contacts: ImmutableList, isCreating: Boolean, - onBack: () -> Unit, + onEditExpiration: () -> Unit, + onPaste: () -> String, onSend: (PaykitPaymentRequestTarget) -> Unit, ) { var selectedTarget by remember { mutableStateOf(null) } @@ -303,14 +309,49 @@ internal fun PaymentRequestRecipientContent( ) { SheetTopBar( titleText = stringResource(R.string.wallet__payment_request_choose_recipient), - onBack = if (isCreating) null else onBack, + action = { + IconButton( + onClick = onEditExpiration, + enabled = !isCreating, + modifier = Modifier.testTag("PaymentRequestEditExpiration"), + ) { + Icon( + painter = painterResource(R.drawable.ic_timer), + contentDescription = stringResource(R.string.wallet__payment_request_edit_expiration), + tint = Colors.White, + modifier = Modifier.size(24.dp), + ) + } + }, ) Caption13Up(text = stringResource(R.string.wallet__payment_request_recipient), color = Colors.White64) VerticalSpacer(8.dp) - SearchInput( + TextInput( value = query, onValueChange = { query = it }, placeholder = stringResource(R.string.wallet__payment_request_enter_pubky), + singleLine = true, + textStyle = AppTextStyles.BodyM, + trailingIcon = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .clickableAlpha { + query = PubkyPublicKeyFormat.bounded(onPaste()) + } + .padding(horizontal = 12.dp) + .testTag("PaymentRequestRecipientPaste"), + ) { + Icon( + painter = painterResource(R.drawable.ic_clipboard_text), + contentDescription = null, + tint = Colors.White, + modifier = Modifier.size(24.dp), + ) + BodyMSB(text = stringResource(R.string.wallet__payment_request_paste)) + } + }, modifier = Modifier .fillMaxWidth() .testTag("PaymentRequestRecipientSearch"), @@ -330,6 +371,7 @@ internal fun PaymentRequestRecipientContent( isSelected = target == selectedTarget, isEnabled = !isCreating, verticalPadding = 16.dp, + selectionColor = Colors.Brand, modifier = Modifier.testTag("PaymentRequestContact${contact.publicKey}"), ) HorizontalDivider(color = Colors.White10) @@ -475,7 +517,8 @@ private fun PaymentRequestRecipientPreview() { targets = persistentListOf(previewTarget), contacts = persistentListOf(PubkyProfile.placeholder(previewTarget.publicKey)), isCreating = false, - onBack = {}, + onEditExpiration = {}, + onPaste = { "" }, onSend = {}, modifier = Modifier.sheetHeight(), ) diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index df2a9dbfe6..f250a16894 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.paymentrequests import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -17,7 +18,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -509,10 +509,12 @@ internal fun PaymentRequestCard( ) } if (onPay != null || onReject != null) { - HorizontalDivider(color = Colors.White10) Row( horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray5) + .padding(16.dp), ) { SecondaryButton( text = stringResource(R.string.wallet__payment_request_dismiss), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index d97c72887b..9a0e243c08 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -137,11 +137,24 @@ fun ReceiveSheet( }, ) } + composableWithDefaultTransitions { + PaymentRequestDetailsScreen( + amountInputViewModel = paymentRequestAmountViewModel, + initialDraft = paymentRequestDraft, + onBack = { navController.popBackStack() }, + onContinue = { + paymentRequestDraft = it + navController.popBackStack() + }, + ) + } composableWithDefaultTransitions { PaymentRequestRecipientScreen( appViewModel = appViewModel, draft = paymentRequestDraft, - onBack = { navController.popBackStack() }, + onEditExpiration = { + navController.navigateTo(ReceiveRoute.PaymentRequestExpiration) + }, onSent = { createdPaymentRequest = it navController.navigateTo(ReceiveRoute.PaymentRequestSent) @@ -260,7 +273,7 @@ fun ReceiveSheet( note = note, expiresAt = Clock.System.now() + 7.days, ) - navController.navigateTo(ReceiveRoute.PaymentRequestDetails) + navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) }, navigateReceiveConfirm = { entry -> cjitEntryDetails.value = entry @@ -326,6 +339,9 @@ sealed interface ReceiveRoute { @Serializable data object PaymentRequestDetails : InternalOnly + @Serializable + data object PaymentRequestExpiration : InternalOnly + @Serializable data object PaymentRequestRecipient : InternalOnly diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be0e8f091d..118599b806 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1173,6 +1173,7 @@ Choose Recipient %1$s - %2$s Dismiss + Edit expiration Enter pubky Expires in 1 day @@ -1183,6 +1184,7 @@ Note What is this payment for? Pay + Paste Your payment request is queued and will send automatically RECIPIENT Request Payment From e2516e9891b6f23c024a9e33d030ce801d25a6d8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 21 Aug 2026 11:15:18 -0500 Subject: [PATCH 8/8] fix: preserve sheet back actions --- app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt b/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt index 3b26eb36ca..71442da66a 100644 --- a/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt +++ b/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt @@ -24,8 +24,8 @@ import to.bitkit.ui.theme.AppThemeSurface fun SheetTopBar( titleText: String?, modifier: Modifier = Modifier, - onBack: (() -> Unit)? = null, action: (@Composable () -> Unit)? = null, + onBack: (() -> Unit)? = null, ) { Box( modifier = modifier