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/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/components/SheetHostTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/SheetHostTest.kt new file mode 100644 index 0000000000..c0be748197 --- /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.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 +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/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..f5fb721010 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreenTest.kt @@ -0,0 +1,122 @@ +@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, + onEditExpiration = {}, + onPaste = { target.publicKey }, + onSend = {}, + ) + } + } + + 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") + + 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..436b703804 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -0,0 +1,155 @@ +@file:OptIn(ExperimentalTime::class) + +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 +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 { + PaymentRequestsTestSurface { + PaymentRequestsSheetContent( + requests = persistentListOf(request), + contacts = persistentListOf(), + onNotNow = {}, + onSeeAll = {}, + onPay = {}, + onReject = { Result.success(Unit) }, + ) + } + } + + composeTestRule.onNodeWithTag("PaymentRequestRowincoming").assertIsDisplayed() + composeTestRule.onNodeWithTag("MoneyPrimary").assertIsDisplayed() + composeTestRule.onNodeWithTag("MoneySecondary").assertIsDisplayed() + composeTestRule.onNodeWithTag("PaymentRequestsSeeAll").assertIsDisplayed() + composeTestRule.onNodeWithText("Dismiss").assertIsDisplayed() + } + + @Test + fun historyGroupsCompletedRequestsAndKeepsActiveOutgoingRequests() { + val now = Clock.System.now() + val accepted = request(id = "accepted").copy( + createdAt = now, + lifecycleState = PaymentRequestLifecycleState.ACCEPTED, + ) + val outgoing = request(id = "outgoing").copy( + createdAt = now, + direction = PaykitPaymentRequestDirection.Outgoing, + deliveryStatus = PaykitPaymentRequestDeliveryStatus.Sent, + ) + + composeTestRule.setContent { + PaymentRequestsTestSurface { + 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("TODAY").assertIsDisplayed() + 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", + 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"), + ) +} + +@Composable +private fun PaymentRequestsTestSurface(content: @Composable () -> Unit) { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + content() + } + } +} 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/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..1af88311cf 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,13 @@ 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 direction: PaykitPaymentRequestDirection = PaykitPaymentRequestDirection.Incoming, + val lifecycleState: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, ) { val id: PaykitPaymentRequestId get() = PaykitPaymentRequestId(paymentRequestId, counterparty, counterpartyReceiverPath) @@ -64,15 +87,40 @@ data class PaykitPaymentRequest( fun acceptsPaymentAmount(amountSats: ULong): Boolean = amountSats == this.amountSats } +enum class PaykitPaymentRequestDeliveryStatus { Queued, Sent } + +enum class PaykitPaymentRequestDirection { Incoming, Outgoing } + +data class PaykitPaymentRequestTarget( + val publicKey: String, + val receiverPath: String, +) + +data class PaykitPaymentRequestDraft( + val amountSats: ULong, + val note: String, + 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") + 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 +128,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 _paymentRequestHistory = MutableStateFlow>(emptyList()) + val paymentRequestHistory: StateFlow> = _paymentRequestHistory.asStateFlow() + private val _eligibleTargets = MutableStateFlow>(emptyList()) + val eligibleTargets: StateFlow> = _eligibleTargets.asStateFlow() + private val _isCreatingRequest = MutableStateFlow(false) + val isCreatingRequest: StateFlow = _isCreatingRequest.asStateFlow() - suspend fun refresh(): Result { + @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(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,7 +199,82 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - suspend fun accept(request: PaykitPaymentRequest): Result = updateRequest(request) { + 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, + ) + publishCreatedRequest(request, generation, expectedIdentity) + } + } finally { + _isCreatingRequest.update { false } + creationMutex.unlock() + } + }.onFailure { + Logger.warn("Failed to create Paykit payment request", it, context = TAG) + } + } + + private fun publishCreatedRequest( + request: PaykitPaymentRequest, + generation: Long, + creatorIdentity: String, + ): PaykitPaymentRequestCreation { + val wasPublishedToActiveState = isCurrentState(generation, creatorIdentity) + if (wasPublishedToActiveState) { + _paymentRequestHistory.update { requests -> + listOf(request) + requests.filterNot { it.id == request.id } + } + scheduleExpirationLocked() + } + return PaykitPaymentRequestCreation(request, creatorIdentity, wasPublishedToActiveState) + } + + suspend fun accept(request: PaykitPaymentRequest): Result = updateRequest( + request = request, + resultingState = PaymentRequestLifecycleState.ACCEPTED, + ) { paykitSdkService.acceptPaymentRequest( counterparty = it.counterparty, counterpartyReceiverPath = it.counterpartyReceiverPath, @@ -111,67 +284,177 @@ class PaykitPaymentRequestRepo @Inject constructor( Logger.warn("Failed to accept incoming Paykit payment request", it, context = TAG) } + suspend fun reject(request: PaykitPaymentRequest): Result = updateRequest( + request = request, + resultingState = PaymentRequestLifecycleState.REJECTED, + ) { + 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 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 || + !PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity) + ) { + return } - if (stateGeneration.get() != generation) return - _pendingRequests.update { requests } + _pendingRequests.update { incoming } + _paymentRequestHistory.update { history } + _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, + resultingState: PaymentRequestLifecycleState, 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) + 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() - 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 +469,42 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - private fun discardExpiredRequestsLocked() { + private suspend fun discardExpiredRequestsLocked() { val now = clock.now() _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } + _paymentRequestHistory.update { requests -> requests.withExpiredLifecycle(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() } + _paymentRequestHistory.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 + _paymentRequestHistory.value) + .asSequence() + .filter { it.lifecycleState == PaymentRequestLifecycleState.PROPOSED } + .mapNotNull { it.expiresAt } + .minOrNull() + ?: return val delayDuration = (nextExpiration - clock.now()).coerceAtLeast(Duration.ZERO) expirationJob = repoScope.launch { delay(delayDuration) @@ -208,11 +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("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, + requiresActionableRequest: Boolean = true, +): PaykitPaymentRequest? { + 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() @@ -221,12 +542,12 @@ private fun PaymentRequestRecord.toPaykitPaymentRequest(now: Instant): PaykitPay 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, @@ -234,11 +555,84 @@ 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 + }, + 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, + 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 + }, + direction = PaykitPaymentRequestDirection.Outgoing, + lifecycleState = PaymentRequestLifecycleState.PROPOSED, + ) +} + +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..b6b34e08a1 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,26 @@ 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 +211,7 @@ class PaykitSdkService @Inject constructor( suspend fun hasPrivatePaymentAccess(): Boolean { isSetup.await() return operationMutex.withLock { - sessionProvider.hasSessionAccess() + handle().identityStatus()?.liveSessionAvailable == true } } @@ -474,7 +506,7 @@ class PaykitSdkService @Inject constructor( return@withStateRevisionTracking } - handle.publishPaykitReceiverMarker(receiverCapabilities()) + handle.publishPaykitReceiverMarker(receiverCapabilities(handle)) } } } @@ -548,10 +580,63 @@ class PaykitSdkService @Inject constructor( } } - suspend fun actionableReceivedPaymentRequests(): List { + 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 { - handle().actionableReceivedPaymentRequests() + 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) + } } } @@ -568,6 +653,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 +842,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 +851,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 +1005,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 +1016,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 +1039,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 +1059,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..a8222c8085 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,17 @@ private fun RootNavHost( activityListViewModel = activityListViewModel, navController = navController, ) + composableWithDefaultTransitions { + PaykitRouteGuard(settingsViewModel, navController) { + PaymentRequestsScreen( + appViewModel = appViewModel, + onBack = { navController.popBackStack() }, + onRequestPayment = { + appViewModel.showSheet(Sheet.Receive(route = ReceiveRoute.PaymentRequestDetails)) + }, + ) + } + } settings(navController, settingsViewModel) contacts(navController, settingsViewModel, appViewModel) profile(navController, settingsViewModel) @@ -2293,6 +2320,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/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 50e2999dd1..6aa6ba909b 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,54 @@ 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 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/components/PubkyContactRow.kt b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt new file mode 100644 index 0000000000..f2e837add0 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/PubkyContactRow.kt @@ -0,0 +1,83 @@ +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.graphics.Color +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, + selectionColor: Color = Colors.PubkyGreen, +) { + 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 = selectionColor, + modifier = Modifier.size(24.dp) + ) + } + } +} 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/scaffold/SheetTopBar.kt b/app/src/main/java/to/bitkit/ui/scaffold/SheetTopBar.kt index 5f5bc53989..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,6 +24,7 @@ import to.bitkit.ui.theme.AppThemeSurface fun SheetTopBar( titleText: String?, modifier: Modifier = Modifier, + action: (@Composable () -> Unit)? = null, onBack: (() -> Unit)? = null, ) { Box( @@ -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 new file mode 100644 index 0000000000..94e1d431a1 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/CreatePaymentRequestScreen.kt @@ -0,0 +1,542 @@ +@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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.ext.getClipboardText +import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.repositories.AmountInputHandler +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDeliveryStatus +import to.bitkit.repositories.PaykitPaymentRequestDraft +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 +import to.bitkit.ui.components.Display +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.PubkyContactRow +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 +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, +) { + 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())) + } + + 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) + 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), + ) + } + } + 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"), + ) + } + VerticalSpacer(16.dp) + } +} + +@Composable +fun PaymentRequestRecipientScreen( + appViewModel: AppViewModel, + draft: PaykitPaymentRequestDraft, + 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() + + PaymentRequestRecipientContent( + targets = targets.toImmutableList(), + contacts = contacts.toImmutableList(), + isCreating = isCreating, + onEditExpiration = onEditExpiration, + onPaste = { context.getClipboardText()?.trim().orEmpty() }, + onSend = { target -> appViewModel.createPaymentRequest(draft, target, onSent) }, + ) +} + +@Composable +internal fun PaymentRequestRecipientContent( + modifier: Modifier = Modifier, + targets: ImmutableList, + contacts: ImmutableList, + isCreating: Boolean, + onEditExpiration: () -> Unit, + onPaste: () -> String, + 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(recipients) { + if (recipients.none { (target, _) -> target == selectedTarget }) 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), + 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) + 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"), + ) + 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( + items = recipients, + key = { (target, _) -> "${target.publicKey}|${target.receiverPath}" }, + ) { (target, contact) -> + PubkyContactRow( + profile = contact, + onClick = { selectedTarget = target }, + isSelected = target == selectedTarget, + isEnabled = !isCreating, + verticalPadding = 16.dp, + selectionColor = Colors.Brand, + modifier = Modifier.testTag("PaymentRequestContact${contact.publicKey}"), + ) + HorizontalDivider(color = Colors.White10) + } + } + PrimaryButton( + text = stringResource(R.string.wallet__payment_request_send_request), + enabled = !isCreating && selectedTarget != null && selectedTarget in targets, + isLoading = isCreating, + onClick = { + val target = selectedTarget ?: return@PrimaryButton + onSend(target) + }, + modifier = Modifier.testTag("PaymentRequestSend"), + ) + VerticalSpacer(16.dp) + } +} + +@Composable +fun PaymentRequestSentScreen( + appViewModel: AppViewModel, + request: PaykitPaymentRequest, + onDone: () -> Unit, +) { + val contacts by appViewModel.pubkyContacts.collectAsStateWithLifecycle() + val contact = contacts.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, request.counterparty) } + PaymentRequestSentContent(request = request, contact = contact, onDone = onDone) +} + +@Composable +internal fun PaymentRequestSentContent( + modifier: Modifier = Modifier, + request: PaykitPaymentRequest, + contact: PubkyProfile?, + onDone: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.Start, + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("PaymentRequestSent"), + ) { + SheetTopBar(titleText = stringResource(R.string.wallet__payment_request_sent_title)) + VerticalSpacer(32.dp) + Image( + painter = painterResource(R.drawable.check), + contentDescription = null, + modifier = Modifier + .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), + ) + VerticalSpacer(12.dp) + BodyM( + 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_sending) + }, + ) + VerticalSpacer(32.dp) + PrimaryButton( + text = stringResource(R.string.common__ok), + onClick = onDone, + ) + VerticalSpacer(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 + } +) + +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, + onEditExpiration = {}, + onPaste = { "" }, + 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 new file mode 100644 index 0000000000..f250a16894 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -0,0 +1,616 @@ +@file:OptIn(ExperimentalTime::class) + +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 +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.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.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.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 +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.ui.utils.withAccent +import to.bitkit.viewmodels.AppViewModel +import java.time.ZoneId +import java.time.temporal.TemporalAdjusters +import java.time.temporal.WeekFields +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( + 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() + } + + 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 + .fillMaxWidth() + .sheetHeight() + .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), + ) + VerticalSpacer(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 = { onPay(request.id) }, + onReject = { onReject(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"), + ) + } + VerticalSpacer(16.dp) + } +} + +@Composable +fun PaymentRequestsScreen( + appViewModel: AppViewModel, + onBack: () -> Unit, + onRequestPayment: () -> Unit, +) { + 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()) + + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .navigationBarsPadding() + .testTag("PaymentRequestsScreen") + ) { + AppTopBar( + titleText = stringResource(R.string.wallet__payment_requests), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + 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) + } + } 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, + ) + } + } + sections.history.forEach { section -> + item(key = "history-${section.period.name}") { + Caption13Up( + text = paymentRequestHistorySectionTitle(section.period), + color = Colors.White64, + ) + } + items(section.requests, key = { it.lazyListKey }) { request -> + PaymentRequestCard( + request = request, + contact = contacts.contactFor(request), + compactSubtitle = paymentRequestDate(request), + ) + } + } + 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 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, + 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 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 +private fun ActivePaymentRequestCard( + request: PaykitPaymentRequest, + isIncoming: Boolean, + contact: PubkyProfile?, + 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(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.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 +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 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 = 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() + .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, + 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, + ) + } + MoneyCell( + sats = request.amountSats.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(), + ) + } + if (onPay != null || onReject != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray5) + .padding(16.dp), + ) { + SecondaryButton( + text = stringResource(R.string.wallet__payment_request_dismiss), + 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" + +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) }, + ) + } + } +} + +@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 2f9f780e8c..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 @@ -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 @@ -235,6 +238,7 @@ 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 homeUiState by homeViewModel.uiState.collectAsStateWithLifecycle() @@ -272,7 +276,10 @@ fun HomeScreen( profileDisplayName = profileDisplayName, profileDisplayImageUri = profileDisplayImageUri, showProfileButton = isPaykitEnabled, + showPaymentRequests = isPaykitEnabled && pendingPaymentRequests.isNotEmpty(), + pendingPaymentRequestCount = pendingPaymentRequests.size, onClickProfile = navigateToProfile, + onClickPaymentRequests = appViewModel::showPaymentRequests, latestActivities = latestActivities, hardwareIds = hardwareIds, onRefresh = { @@ -397,7 +404,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 +504,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 +1320,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 +1368,33 @@ 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"), + ) { + Icon( + painter = painterResource(R.drawable.ic_bell), + tint = Colors.Brand, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + } IconButton( onClick = onOpenDrawer, modifier = Modifier.testTag("HeaderMenu") 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/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index e4a4bcc760..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 @@ -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 = 0uL, + note = "", + expiresAt = Clock.System.now() + 7.days, + ) + ) + } + var createdPaymentRequest by remember { mutableStateOf(null) } LaunchedEffect(Unit) { wallet.resetPreActivityMetadataTagsForCurrentInvoice() @@ -102,6 +126,50 @@ fun ReceiveSheet( onClickEditInvoice = { navController.navigateTo(ReceiveRoute.EditInvoice) }, ) } + composableWithDefaultTransitions { + PaymentRequestDetailsScreen( + amountInputViewModel = paymentRequestAmountViewModel, + initialDraft = paymentRequestDraft, + onBack = { navController.popBackStack() }, + onContinue = { + paymentRequestDraft = it + navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) + }, + ) + } + composableWithDefaultTransitions { + PaymentRequestDetailsScreen( + amountInputViewModel = paymentRequestAmountViewModel, + initialDraft = paymentRequestDraft, + onBack = { navController.popBackStack() }, + onContinue = { + paymentRequestDraft = it + navController.popBackStack() + }, + ) + } + composableWithDefaultTransitions { + PaymentRequestRecipientScreen( + appViewModel = appViewModel, + draft = paymentRequestDraft, + onEditExpiration = { + navController.navigateTo(ReceiveRoute.PaymentRequestExpiration) + }, + onSent = { + createdPaymentRequest = it + navController.navigateTo(ReceiveRoute.PaymentRequestSent) + }, + ) + } + composableWithDefaultTransitions { + createdPaymentRequest?.let { + PaymentRequestSentScreen( + appViewModel = appViewModel, + request = it, + onDone = appViewModel::hideSheet, + ) + } + } composableWithDefaultTransitions { ReceiveAmountScreen( onCjitCreated = { entry -> @@ -198,6 +266,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.PaymentRequestRecipient) + }, navigateReceiveConfirm = { entry -> cjitEntryDetails.value = entry navController.navigateTo(ReceiveRoute.ConfirmIncreaseInbound) @@ -259,6 +336,18 @@ sealed interface ReceiveRoute { @Serializable data object AddTag : DeepLinkStart + @Serializable + data object PaymentRequestDetails : InternalOnly + + @Serializable + data object PaymentRequestExpiration : 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/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 4b71ee0f05..5a837f3e9e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -143,9 +143,12 @@ 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 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 +291,13 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() + val pendingPaymentRequests = paykitPaymentRequestRepo.pendingRequests + val paymentRequestHistory = paykitPaymentRequestRepo.paymentRequestHistory + 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 +312,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 +584,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 +688,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 +721,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 +738,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 +759,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 suspend fun presentIncomingPaymentRequestOrStop(request: PaykitPaymentRequest): Boolean { + 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, + 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 +839,6 @@ class AppViewModel @Inject constructor( return false } - clearPaymentRequestPresentationRetry(request.id) - presentedPaymentRequestIds += request.id openContactPayment( paymentRequest = result.paymentRequest, publicKey = request.counterparty, @@ -757,13 +848,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 +883,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 +898,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 +1890,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 +2338,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 +2914,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 +3571,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 +3816,77 @@ 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) + } + + private 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, + ) { + val sourceReceiveSheet = currentSheet.value as? Sheet.Receive + viewModelScope.launch { + createPaymentRequest(draft, target) + .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) } + } + + private fun handlePaymentPreparationFailure(error: Throwable) { + toast(error) hideSheet() } @@ -3921,12 +4129,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..118599b806 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 @@ -1168,7 +1169,56 @@ The recipient rejected this Lightning invoice request. The recipient rejected this Lightning payment. Please check the invoice and try again. Payment Request + Amount + Choose Recipient + %1$s - %2$s + Dismiss + Edit expiration + Enter pubky + Expires in + 1 day + 1 hour + 1 month + 1 week The payment details did not match the request. Payment cancelled. + Note + What is this payment for? + Pay + Paste + Your payment request is queued and will send automatically + RECIPIENT + Request Payment + Send Payment Request + Send Request + You have sent a payment request + 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 + You have not made any payments to providers and don’t have any payment requests yet. + History]]> + Earlier + 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. diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 47d388b48a..6b8d661759 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( @@ -62,8 +79,12 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { schedulerOriginMillis = testDispatcher.scheduler.currentTime 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 @@ -74,9 +95,9 @@ 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().getOrThrow() + sut.refresh(emptyList()).getOrThrow() val request = sut.pendingRequests.value.single() assertEquals(100_000uL, request.amountSats) @@ -85,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"), @@ -95,7 +116,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 }) @@ -121,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")), @@ -129,17 +150,52 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), ) - sut.refresh().getOrThrow() + sut.refresh(emptyList()).getOrThrow() 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().getOrThrow() + sut.refresh(emptyList()).getOrThrow() advanceTimeBy(9_999) runCurrent() @@ -148,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, @@ -161,20 +245,236 @@ 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() 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.paymentRequests()).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()) + 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.paymentRequests()).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.paymentRequests()).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.paymentRequestHistory.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.paymentRequests()).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.paymentRequestHistory.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 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) { + 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) + 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.paymentRequestHistory.value.isEmpty()) + } + + @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() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + sut.refresh(emptyList()).getOrThrow() val request = sut.pendingRequests.value.single() advanceTimeBy(1_000) @@ -189,8 +489,8 @@ 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)) - sut.refresh().getOrThrow() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + sut.refresh(emptyList()).getOrThrow() val request = sut.pendingRequests.value.single() advanceTimeBy(1_000) @@ -201,15 +501,18 @@ 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), + counterparty: String = COUNTERPARTY, + receiverPath: String = PaykitReceiverPaths.SERVER, ) = PaymentRequestRecord( - counterparty = COUNTERPARTY, - counterpartyReceiverPath = PaykitReceiverPaths.SERVER, + counterparty = counterparty, + counterpartyReceiverPath = receiverPath, paymentRequestId = id, localRole = role, - state = PaymentRequestLifecycleState.PROPOSED, + state = state, proposalStreamItemId = 1uL, proposalOutboundMessageId = null, proposalOutboundStatus = null, @@ -235,4 +538,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..a8d6d5218e 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -85,7 +85,11 @@ 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 +import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution @@ -127,7 +131,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 +189,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val pubkyContacts = MutableStateFlow>(emptyList()) private val pubkyContactsLoadVersion = MutableStateFlow(0L) private val pendingPaykitPaymentRequests = MutableStateFlow>(emptyList()) + private val paykitPaymentRequestHistory = MutableStateFlow>(emptyList()) + private val surfacedPaykitPaymentRequestIds = mutableSetOf() private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private val timedSheetManager = mock() @@ -193,6 +199,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Before fun setUp() { timedSheetType.value = null + paykitPaymentRequestHistory.value = emptyList() + surfacedPaykitPaymentRequestIds.clear() stubRepositories() sut = createViewModel() } @@ -241,7 +249,22 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) + whenever(paykitPaymentRequestRepo.paymentRequestHistory).thenReturn(paykitPaymentRequestHistory) + 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 +461,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 +472,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 +657,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 +705,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 +2738,104 @@ 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() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.propose(draft, target, emptyList())).doSuspendableAnswer { + creationStarted.complete(Unit) + finishCreation.await() + Result.success(paymentRequestCreation(request)) + } + + sut.createPaymentRequest(draft, target) { callbackRequest.complete(it) } + creationStarted.await() + finishCreation.complete(Unit) + runCurrent() + + 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 @@ -3415,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 = diff --git a/changelog.d/next/1172.added.md b/changelog.d/next/1172.added.md new file mode 100644 index 0000000000..bffb6f1b47 --- /dev/null +++ b/changelog.d/next/1172.added.md @@ -0,0 +1 @@ +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 41012a0489..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-rc43" } +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" }