From 2129adaa8d7362644a2d81375c9a88c388081a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Honza=20Rychnovsk=C3=BD?= Date: Mon, 21 Oct 2024 21:11:10 +0200 Subject: [PATCH] [#1595] Build Request ZEC Uri Consume Part (#1642) * Payment Request screen logic update * Scan view model implementation * Zip321 Uri parsing and passing around screens * Pass PaymentRequestArguments * Fixed screens navigation * Screen balances UI part * Address UI part * Address UI part + logic * Memo UI part * Amounts UI part * Add stages and send logic with authentication * Send transaction error handling * Code analysis warnings fix * Tests update * Changelogs * [#1595] QR code design update * Address review comments --------- Co-authored-by: Milan Cerovsky --- CHANGELOG.md | 1 + docs/whatsNew/WHATS_NEW_EN.md | 1 + gradle.properties | 2 +- .../zcash/ui/design/component/ZashiButton.kt | 2 +- .../scan/view/ScanViewIntegrationTest.kt | 8 +- .../test/screen/scan/view/ScanViewTest.kt | 10 +- .../screen/scan/view/ScanViewTestSetup.kt | 10 +- ui-lib/build.gradle.kts | 1 + .../ui/screen/scan/view/ScanViewBasicTest.kt | 4 +- .../scan/view/ScanViewBasicTestSetup.kt | 10 +- .../integration/SendViewIntegrationTest.kt | 1 + .../co/electriccoin/zcash/di/UseCaseModule.kt | 8 +- .../electriccoin/zcash/di/ViewModelModule.kt | 13 +- .../co/electriccoin/zcash/ui/Navigation.kt | 75 ++- .../common/usecase/Zip321BuildUriUseCase.kt | 4 +- .../Zip321ParseUriValidationUseCase.kt | 55 ++ .../usecase/Zip321ProposalFromUriUseCase.kt | 19 + .../zcash/ui/screen/home/AndroidHome.kt | 4 + .../paymentrequest/AndroidPaymentRequest.kt | 130 +++++ .../PaymentRequestArgumentsFixture.kt | 23 + .../model/PaymentRequestArguments.kt | 51 ++ .../model/PaymentRequestStage.kt | 16 + .../model/PaymentRequestState.kt | 25 + .../paymentrequest/view/PaymentRequestView.kt | 545 ++++++++++++++++++ .../viewmodel/PaymentRequestViewModel.kt | 239 ++++++++ .../zcash/ui/screen/qrcode/view/QrCodeView.kt | 10 + .../screen/request/view/RequestQrCodeView.kt | 10 + .../zcash/ui/screen/scan/AndroidScan.kt | 97 ++-- .../ui/screen/scan/model/ScanResultState.kt | 7 + .../{ScanState.kt => ScanScreenState.kt} | 2 +- .../screen/scan/model/ScanValidationState.kt | 7 + .../zcash/ui/screen/scan/view/ScanView.kt | 73 ++- .../ui/screen/scan/viewmodel/ScanViewModel.kt | 94 +++ .../zcash/ui/screen/send/AndroidSend.kt | 92 +++ .../ui/screen/send/model/SendArguments.kt | 1 + .../drawable/ic_chevron_down.xml | 13 + .../drawable/ic_chevron_up.xml | 13 + .../payment_request/drawable/ic_user_plus.xml | 13 + .../res/ui/payment_request/values/strings.xml | 29 + 39 files changed, 1570 insertions(+), 148 deletions(-) create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321ParseUriValidationUseCase.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321ProposalFromUriUseCase.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/AndroidPaymentRequest.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/PaymentRequestArgumentsFixture.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/model/PaymentRequestArguments.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/model/PaymentRequestStage.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/model/PaymentRequestState.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/view/PaymentRequestView.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/viewmodel/PaymentRequestViewModel.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanResultState.kt rename ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/{ScanState.kt => ScanScreenState.kt} (76%) create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanValidationState.kt create mode 100644 ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/viewmodel/ScanViewModel.kt create mode 100644 ui-lib/src/main/res/ui/payment_request/drawable/ic_chevron_down.xml create mode 100644 ui-lib/src/main/res/ui/payment_request/drawable/ic_chevron_up.xml create mode 100644 ui-lib/src/main/res/ui/payment_request/drawable/ic_user_plus.xml create mode 100644 ui-lib/src/main/res/ui/payment_request/values/strings.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index eb1bc358..02e1fc9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this application adheres to [Semantic Versioning](https://semver.org/spec/v2 - Confirmation screen redesigned & added a contact name to the transaction if the contact is in address book - History item redesigned & added an option to create a contact from unknown address - Address Book, Create/Update/Delete Contact, Create Contact by QR screens added +- The Scan QR code screen now supports scanning of ZIP 321 Uris ### Added - Address book local storage support diff --git a/docs/whatsNew/WHATS_NEW_EN.md b/docs/whatsNew/WHATS_NEW_EN.md index 7276162c..52045da0 100644 --- a/docs/whatsNew/WHATS_NEW_EN.md +++ b/docs/whatsNew/WHATS_NEW_EN.md @@ -18,6 +18,7 @@ directly impact users rather than highlighting other key architectural updates.* - Confirmation screen redesigned & added a contact name to the transaction if the contact is in address book - History item redesigned & added an option to create a contact from unknown address - Address Book, Create/Update/Delete Contact, Create Contact by QR screens added +- The Scan QR code screen now supports scanning of ZIP 321 Uris ### Added - New Integrations screen in settings diff --git a/gradle.properties b/gradle.properties index b3f4b7b5..8b1e73b1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -210,7 +210,7 @@ PLAY_PUBLISHER_API_VERSION=v3-rev20231030-2.0.0 PLAY_SERVICES_AUTH_VERSION=21.2.0 ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0 ZXING_VERSION=3.5.3 -ZIP_321_VERSION = 0.0.3 +ZIP_321_VERSION = 0.0.6 ZCASH_BIP39_VERSION=1.0.8 # WARNING: Ensure a non-snapshot version is used before releasing to production diff --git a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiButton.kt b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiButton.kt index 6e0515c1..32b6d562 100644 --- a/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiButton.kt +++ b/ui-design-lib/src/main/java/co/electriccoin/zcash/ui/design/component/ZashiButton.kt @@ -83,7 +83,7 @@ fun ZashiButton( @Composable override fun Loading() { - if (enabled && isLoading) { + if (isLoading) { LottieProgress( loadingRes = if (isSystemInDarkTheme()) { diff --git a/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewIntegrationTest.kt b/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewIntegrationTest.kt index 95598f67..b9b0a368 100644 --- a/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewIntegrationTest.kt +++ b/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewIntegrationTest.kt @@ -9,7 +9,7 @@ import co.electriccoin.zcash.test.UiTestPrerequisites import co.electriccoin.zcash.ui.integration.test.common.IntegrationTestingActivity import co.electriccoin.zcash.ui.integration.test.common.getPermissionPositiveButtonUiObject import co.electriccoin.zcash.ui.screen.scan.ScanTag -import co.electriccoin.zcash.ui.screen.scan.model.ScanState +import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Before @@ -36,15 +36,15 @@ class ScanViewIntegrationTest : UiTestPrerequisites() { testSetup.DefaultContent() } - assertEquals(testSetup.getScanState(), ScanState.Permission) + assertEquals(testSetup.getScanState(), ScanScreenState.Permission) testSetup.grantPermission() - assertEquals(testSetup.getScanState(), ScanState.Scanning) + assertEquals(testSetup.getScanState(), ScanScreenState.Scanning) restorationTester.emulateSavedInstanceStateRestore() - assertEquals(testSetup.getScanState(), ScanState.Scanning) + assertEquals(testSetup.getScanState(), ScanScreenState.Scanning) } @Test diff --git a/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewTest.kt b/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewTest.kt index 84941ca7..8215524c 100644 --- a/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewTest.kt +++ b/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewTest.kt @@ -15,7 +15,7 @@ import co.electriccoin.zcash.ui.integration.test.common.getStringResource import co.electriccoin.zcash.ui.integration.test.common.getStringResourceWithArgs import co.electriccoin.zcash.ui.integration.test.common.waitForDeviceIdle import co.electriccoin.zcash.ui.screen.scan.ScanTag -import co.electriccoin.zcash.ui.screen.scan.model.ScanState +import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -62,7 +62,7 @@ class ScanViewTest : UiTestPrerequisites() { @Test @LargeTest fun grant_camera_permission() { - assertEquals(ScanState.Permission, testSetup.getScanState()) + assertEquals(ScanScreenState.Permission, testSetup.getScanState()) composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also { it.assertDoesNotExist() @@ -78,7 +78,7 @@ class ScanViewTest : UiTestPrerequisites() { it.assertIsDisplayed() } - assertEquals(ScanState.Scanning, testSetup.getScanState()) + assertEquals(ScanScreenState.Scanning, testSetup.getScanState()) // we need to actively wait for the camera preview initialization waitForDeviceIdle(timeout = 5000.milliseconds) @@ -91,11 +91,11 @@ class ScanViewTest : UiTestPrerequisites() { @Test @LargeTest fun deny_camera_permission() { - assertEquals(ScanState.Permission, testSetup.getScanState()) + assertEquals(ScanScreenState.Permission, testSetup.getScanState()) testSetup.denyPermission() - assertEquals(ScanState.Permission, testSetup.getScanState()) + assertEquals(ScanScreenState.Permission, testSetup.getScanState()) composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also { it.assertDoesNotExist() diff --git a/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewTestSetup.kt b/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewTestSetup.kt index 16040a1e..d90894d4 100644 --- a/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewTestSetup.kt +++ b/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewTestSetup.kt @@ -3,12 +3,12 @@ package co.electriccoin.zcash.ui.integration.test.screen.scan.view import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.ui.test.junit4.ComposeContentTestRule -import cash.z.ecc.android.sdk.type.AddressType import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.integration.test.common.getPermissionNegativeButtonUiObject import co.electriccoin.zcash.ui.integration.test.common.getPermissionPositiveButtonUiObject -import co.electriccoin.zcash.ui.screen.scan.model.ScanState +import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState +import co.electriccoin.zcash.ui.screen.scan.model.ScanValidationState import co.electriccoin.zcash.ui.screen.scan.view.Scan import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -19,14 +19,14 @@ class ScanViewTestSetup( private val composeTestRule: ComposeContentTestRule ) { private val onOpenSettingsCount = AtomicInteger(0) - private val scanState = AtomicReference(ScanState.Permission) + private val scanState = AtomicReference(ScanScreenState.Permission) fun getOnOpenSettingsCount(): Int { composeTestRule.waitForIdle() return onOpenSettingsCount.get() } - fun getScanState(): ScanState { + fun getScanState(): ScanScreenState { composeTestRule.waitForIdle() return scanState.get() } @@ -61,7 +61,7 @@ class ScanViewTestSetup( scanState.set(it) }, topAppBarSubTitleState = TopAppBarSubTitleState.None, - addressValidationResult = AddressType.Unified + validationResult = ScanValidationState.VALID ) } diff --git a/ui-lib/build.gradle.kts b/ui-lib/build.gradle.kts index 8b3458cf..25ac48b0 100644 --- a/ui-lib/build.gradle.kts +++ b/ui-lib/build.gradle.kts @@ -47,6 +47,7 @@ android { "src/main/res/ui/integrations", "src/main/res/ui/new_wallet_recovery", "src/main/res/ui/onboarding", + "src/main/res/ui/payment_request", "src/main/res/ui/qr_code", "src/main/res/ui/request", "src/main/res/ui/receive", diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanViewBasicTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanViewBasicTest.kt index 1eea3d9b..60244e3b 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanViewBasicTest.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanViewBasicTest.kt @@ -12,7 +12,7 @@ import androidx.test.rule.GrantPermissionRule import co.electriccoin.zcash.test.UiTestPrerequisites import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.screen.scan.ScanTag -import co.electriccoin.zcash.ui.screen.scan.model.ScanState +import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState import co.electriccoin.zcash.ui.test.getStringResource import org.junit.Assert.assertEquals import org.junit.Rule @@ -80,7 +80,7 @@ class ScanViewBasicTest : UiTestPrerequisites() { fun scan_state() { val testSetup = newTestSetup() - assertEquals(ScanState.Scanning, testSetup.getScanState()) + assertEquals(ScanScreenState.Scanning, testSetup.getScanState()) } private fun newTestSetup() = diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanViewBasicTestSetup.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanViewBasicTestSetup.kt index 1f179097..26da8f74 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanViewBasicTestSetup.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanViewBasicTestSetup.kt @@ -3,10 +3,10 @@ package co.electriccoin.zcash.ui.screen.scan.view import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.ui.test.junit4.ComposeContentTestRule -import cash.z.ecc.android.sdk.type.AddressType import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState import co.electriccoin.zcash.ui.design.theme.ZcashTheme -import co.electriccoin.zcash.ui.screen.scan.model.ScanState +import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState +import co.electriccoin.zcash.ui.screen.scan.model.ScanValidationState import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference @@ -14,14 +14,14 @@ class ScanViewBasicTestSetup( private val composeTestRule: ComposeContentTestRule ) { private val onBackCount = AtomicInteger(0) - private val scanState = AtomicReference(ScanState.Permission) + private val scanState = AtomicReference(ScanScreenState.Permission) fun getOnBackCount(): Int { composeTestRule.waitForIdle() return onBackCount.get() } - fun getScanState(): ScanState { + fun getScanState(): ScanScreenState { composeTestRule.waitForIdle() return scanState.get() } @@ -30,7 +30,7 @@ class ScanViewBasicTestSetup( @Suppress("TestFunctionName") fun DefaultContent() { Scan( - addressValidationResult = AddressType.Shielded, + validationResult = ScanValidationState.VALID, onBack = { onBackCount.incrementAndGet() }, diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/integration/SendViewIntegrationTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/integration/SendViewIntegrationTest.kt index b93788f2..ec7dc7c9 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/integration/SendViewIntegrationTest.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/integration/SendViewIntegrationTest.kt @@ -39,6 +39,7 @@ class SendViewIntegrationTest { goBack = {}, goBalances = {}, goSettings = {}, + goPaymentRequest = { _, _ -> }, goSendConfirmation = {}, ) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt index 519fede7..6852b6c0 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt @@ -29,6 +29,8 @@ import co.electriccoin.zcash.ui.common.usecase.ValidateContactAddressUseCase import co.electriccoin.zcash.ui.common.usecase.ValidateContactNameUseCase import co.electriccoin.zcash.ui.common.usecase.ValidateEndpointUseCase import co.electriccoin.zcash.ui.common.usecase.Zip321BuildUriUseCase +import co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase +import co.electriccoin.zcash.ui.common.usecase.Zip321ProposalFromUriUseCase import org.koin.core.module.dsl.singleOf import org.koin.dsl.module @@ -58,9 +60,11 @@ val useCaseModule = singleOf(::ObserveContactPickedUseCase) singleOf(::GetAddressesUseCase) singleOf(::CopyToClipboardUseCase) + singleOf(::ShareImageUseCase) + singleOf(::Zip321BuildUriUseCase) + singleOf(::Zip321ProposalFromUriUseCase) + singleOf(::Zip321ParseUriValidationUseCase) singleOf(::ObserveWalletStateUseCase) singleOf(::IsCoinbaseAvailableUseCase) singleOf(::GetSpendingKeyUseCase) - singleOf(::ShareImageUseCase) - singleOf(::Zip321BuildUriUseCase) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt index ad823eb8..9f5ea2fe 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt @@ -13,11 +13,14 @@ import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel import co.electriccoin.zcash.ui.screen.contact.viewmodel.UpdateContactViewModel import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel +import co.electriccoin.zcash.ui.screen.paymentrequest.viewmodel.PaymentRequestViewModel import co.electriccoin.zcash.ui.screen.qrcode.viewmodel.QrCodeViewModel import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel import co.electriccoin.zcash.ui.screen.request.viewmodel.RequestViewModel import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel import co.electriccoin.zcash.ui.screen.restoresuccess.viewmodel.RestoreSuccessViewModel +import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs +import co.electriccoin.zcash.ui.screen.scan.viewmodel.ScanViewModel import co.electriccoin.zcash.ui.screen.sendconfirmation.viewmodel.CreateTransactionsViewModel import co.electriccoin.zcash.ui.screen.settings.viewmodel.ScreenBrightnessViewModel import co.electriccoin.zcash.ui.screen.settings.viewmodel.SettingsViewModel @@ -67,6 +70,14 @@ val viewModelModule = viewModelOf(::UpdateContactViewModel) viewModelOf(::ReceiveViewModel) viewModelOf(::QrCodeViewModel) - viewModelOf(::IntegrationsViewModel) viewModelOf(::RequestViewModel) + viewModelOf(::PaymentRequestViewModel) + viewModelOf(::IntegrationsViewModel) + viewModel { (args: ScanNavigationArgs) -> + ScanViewModel( + args = args, + getSynchronizer = get(), + zip321ParseUriValidationUseCase = get(), + ) + } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt index 78a58953..9d7b7a0f 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt @@ -22,12 +22,18 @@ import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.getSerializableCompat import co.electriccoin.zcash.ui.NavigationArgs.ADDRESS_TYPE import co.electriccoin.zcash.ui.NavigationArguments.MULTIPLE_SUBMISSION_CLEAR_FORM +import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_ADDRESS +import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_AMOUNT +import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_MEMO +import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_PROPOSAL +import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_URI import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_AMOUNT import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_INITIAL_STAGE import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_MEMO import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_PROPOSAL import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_RECIPIENT_ADDRESS import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_RECIPIENT_ADDRESS +import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_ZIP_321_URI import co.electriccoin.zcash.ui.NavigationTargets.ABOUT import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER @@ -37,6 +43,7 @@ import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA import co.electriccoin.zcash.ui.NavigationTargets.HOME import co.electriccoin.zcash.ui.NavigationTargets.INTEGRATIONS import co.electriccoin.zcash.ui.NavigationTargets.NOT_ENOUGH_SPACE +import co.electriccoin.zcash.ui.NavigationTargets.PAYMENT_REQUEST import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE import co.electriccoin.zcash.ui.NavigationTargets.REQUEST import co.electriccoin.zcash.ui.NavigationTargets.SEED_RECOVERY @@ -71,6 +78,8 @@ import co.electriccoin.zcash.ui.screen.exchangerate.settings.AndroidSettingsExch import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData import co.electriccoin.zcash.ui.screen.home.WrapHome import co.electriccoin.zcash.ui.screen.integrations.WrapIntegrations +import co.electriccoin.zcash.ui.screen.paymentrequest.WrapPaymentRequest +import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestArguments import co.electriccoin.zcash.ui.screen.qrcode.WrapQrCode import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType import co.electriccoin.zcash.ui.screen.request.WrapRequest @@ -250,28 +259,7 @@ internal fun MainActivity.Navigation() { backStackEntry.arguments ?.getSerializableCompat(ScanNavigationArgs.KEY) ?: ScanNavigationArgs.DEFAULT - WrapScanValidator( - onScanValid = { scanResult -> - when (mode) { - ScanNavigationArgs.DEFAULT -> { - navController.previousBackStackEntry?.savedStateHandle?.apply { - set( - SEND_SCAN_RECIPIENT_ADDRESS, - Json.encodeToString(SerializableAddress.serializer(), scanResult) - ) - } - navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE) - } - - ScanNavigationArgs.ADDRESS_BOOK -> { - val address = scanResult.address - navController.popBackStack() - navController.navigate(AddContactArgs(address)) - } - } - }, - goBack = { navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE) } - ) + WrapScanValidator(args = mode) } composable(EXPORT_PRIVATE_DATA) { WrapExportPrivateData( @@ -357,6 +345,13 @@ internal fun MainActivity.Navigation() { val addressType = backStackEntry.arguments?.getInt(ADDRESS_TYPE) ?: ReceiveAddressType.Unified.ordinal WrapRequest(addressType) } + composable(PAYMENT_REQUEST) { + navController.previousBackStackEntry?.let { backStackEntry -> + WrapPaymentRequest( + arguments = PaymentRequestArguments.fromSavedStateHandle(backStackEntry.savedStateHandle) + ) + } + } } } @@ -376,6 +371,12 @@ private fun MainActivity.NavigationHome( } navController.navigateJustOnce(SEND_CONFIRMATION) }, + goPaymentRequest = { zecSend, zip321Uri -> + navController.currentBackStackEntry?.savedStateHandle?.let { handle -> + fillInHandleForPaymentRequest(handle, zecSend, zip321Uri) + } + navController.navigateJustOnce(PAYMENT_REQUEST) + }, goSettings = { navController.navigateJustOnce(SETTINGS) }, goMultiTrxSubmissionFailure = { // Ultimately we could approach reworking the MultipleTrxFailure screen into a separate @@ -391,11 +392,13 @@ private fun MainActivity.NavigationHome( backStack.savedStateHandle.get(SEND_SCAN_RECIPIENT_ADDRESS)?.let { Json.decodeFromString(it).toRecipient() }, + zip321Uri = backStack.savedStateHandle.get(SEND_SCAN_ZIP_321_URI), clearForm = backStack.savedStateHandle.get(MULTIPLE_SUBMISSION_CLEAR_FORM) ?: false ).also { // Remove Send screen arguments passed from the Scan or MultipleSubmissionFailure screens if // some exist after we use them backStack.savedStateHandle.remove(SEND_SCAN_RECIPIENT_ADDRESS) + backStack.savedStateHandle.remove(SEND_SCAN_ZIP_321_URI) backStack.savedStateHandle.remove(MULTIPLE_SUBMISSION_CLEAR_FORM) }, ) @@ -487,6 +490,22 @@ private fun fillInHandleForConfirmation( handle[SEND_CONFIRM_INITIAL_STAGE] = initialStage.toStringName() } +private fun fillInHandleForPaymentRequest( + handle: SavedStateHandle, + zecSend: ZecSend, + zip321: String +) { + handle[PAYMENT_REQUEST_ADDRESS] = + Json.encodeToString( + serializer = SerializableAddress.serializer(), + value = zecSend.destination.toSerializableAddress() + ) + handle[PAYMENT_REQUEST_AMOUNT] = zecSend.amount.value + handle[PAYMENT_REQUEST_MEMO] = zecSend.memo.value + handle[PAYMENT_REQUEST_PROPOSAL] = zecSend.proposal?.toByteArray() ?: byteArrayOf() + handle[PAYMENT_REQUEST_URI] = zip321 +} + private fun NavHostController.navigateJustOnce( route: String, navOptionsBuilder: (NavOptionsBuilder.() -> Unit)? = null @@ -508,7 +527,7 @@ private fun NavHostController.navigateJustOnce( * * @param currentRouteToBePopped current screen which should be popped up. */ -private fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: String) { +fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: String) { if (currentDestination?.route != currentRouteToBePopped) { return } @@ -517,6 +536,7 @@ private fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: Strin object NavigationArguments { const val SEND_SCAN_RECIPIENT_ADDRESS = "send_scan_recipient_address" + const val SEND_SCAN_ZIP_321_URI = "send_scan_zip_321_uri" const val SEND_CONFIRM_RECIPIENT_ADDRESS = "send_confirm_recipient_address" const val SEND_CONFIRM_AMOUNT = "send_confirm_amount" @@ -525,6 +545,12 @@ object NavigationArguments { const val SEND_CONFIRM_INITIAL_STAGE = "send_confirm_initial_stage" const val MULTIPLE_SUBMISSION_CLEAR_FORM = "multiple_submission_clear_form" + + const val PAYMENT_REQUEST_ADDRESS = "payment_request_address" + const val PAYMENT_REQUEST_AMOUNT = "payment_request_amount" + const val PAYMENT_REQUEST_MEMO = "payment_request_memo" + const val PAYMENT_REQUEST_PROPOSAL = "payment_request_proposal" + const val PAYMENT_REQUEST_URI = "payment_request_uri" } object NavigationTargets { @@ -535,7 +561,9 @@ object NavigationTargets { const val EXPORT_PRIVATE_DATA = "export_private_data" const val HOME = "home" const val CHOOSE_SERVER = "choose_server" + const val INTEGRATIONS = "integrations" const val NOT_ENOUGH_SPACE = "not_enough_space" + const val PAYMENT_REQUEST = "payment_request" const val QR_CODE = "qr_code" const val REQUEST = "request" const val SEED_RECOVERY = "seed_recovery" @@ -544,7 +572,6 @@ object NavigationTargets { const val SETTINGS_EXCHANGE_RATE_OPT_IN = "settings_exchange_rate_opt_in" const val SUPPORT = "support" const val WHATS_NEW = "whats_new" - const val INTEGRATIONS = "integrations" } object NavigationArgs { diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321BuildUriUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321BuildUriUseCase.kt index 6aa1a4c1..644dc913 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321BuildUriUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321BuildUriUseCase.kt @@ -42,15 +42,13 @@ class Zip321BuildUriUseCase { val paymentRequest = PaymentRequest(payments = listOf(payment)) - // TODO [#1636]: Use fixed ZIP321 library version - // TODO [#1636]: https://github.com/Electric-Coin-Company/zashi-android/issues/1636 val zip321Uri = ZIP321.uriString( paymentRequest, ZIP321.FormattingOptions.UseEmptyParamIndex(omitAddressLabel = true) ) - Twig.info { "Request Zip321 uri: $zip321Uri" } + Twig.debug { "Request Zip321 uri: $zip321Uri" } return zip321Uri } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321ParseUriValidationUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321ParseUriValidationUseCase.kt new file mode 100644 index 00000000..8588ceca --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321ParseUriValidationUseCase.kt @@ -0,0 +1,55 @@ +package co.electriccoin.zcash.ui.common.usecase + +import cash.z.ecc.android.sdk.type.AddressType +import co.electriccoin.zcash.spackle.Twig +import kotlinx.coroutines.runBlocking +import org.zecdev.zip321.ZIP321 + +internal class Zip321ParseUriValidationUseCase( + private val getSynchronizerUseCase: GetSynchronizerUseCase +) { + operator fun invoke(zip321Uri: String) = validateZip321Uri(zip321Uri) + + private fun validateZip321Uri(zip321Uri: String): Zip321ParseUriValidation { + val paymentRequest = + runCatching { + ZIP321.request( + uriString = zip321Uri, + validatingRecipients = { address -> + // We should be fine with the blocking implementation here + runBlocking { + getSynchronizerUseCase().validateAddress(address).let { validation -> + when (validation) { + is AddressType.Invalid -> { + Twig.error { "Address from Zip321 validation failed: ${validation.reason}" } + false + } + else -> { + validation is AddressType.Valid + } + } + } + } + } + ) + }.onFailure { + Twig.error(it) { "Failed to validate address" } + }.getOrElse { + false + } + + Twig.info { "Payment Request Zip321 validation result: $paymentRequest." } + + return when (paymentRequest) { + is ZIP321.ParserResult.Request -> Zip321ParseUriValidation.Valid(zip321Uri) + // null or [ZIP321.ParserResult.SingleAddress] is not valid for our ZIP 321 Uri to Proposal use case + else -> Zip321ParseUriValidation.Invalid + } + } + + internal sealed class Zip321ParseUriValidation { + data class Valid(val zip321Uri: String) : Zip321ParseUriValidation() + + data object Invalid : Zip321ParseUriValidation() + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321ProposalFromUriUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321ProposalFromUriUseCase.kt new file mode 100644 index 00000000..c9544b46 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321ProposalFromUriUseCase.kt @@ -0,0 +1,19 @@ +package co.electriccoin.zcash.ui.common.usecase + +import cash.z.ecc.android.sdk.model.Account +import cash.z.ecc.android.sdk.model.Proposal +import co.electriccoin.zcash.spackle.Twig + +class Zip321ProposalFromUriUseCase( + private val getSynchronizerUseCase: GetSynchronizerUseCase +) { + suspend operator fun invoke(zip321Uri: String) = getProposal(zip321Uri) + + private suspend fun getProposal(zip321Uri: String): Proposal { + val proposal = getSynchronizerUseCase.invoke().proposeFulfillingPaymentUri(Account.DEFAULT, zip321Uri) + + Twig.info { "Request Zip321 proposal: $proposal" } + + return proposal + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/AndroidHome.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/AndroidHome.kt index d0c75e00..ec1f82fc 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/AndroidHome.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/home/AndroidHome.kt @@ -45,6 +45,7 @@ internal fun WrapHome( goMultiTrxSubmissionFailure: () -> Unit, goScan: () -> Unit, goSendConfirmation: (ZecSend) -> Unit, + goPaymentRequest: (ZecSend, String) -> Unit, sendArguments: SendArguments ) { val homeViewModel = koinActivityViewModel() @@ -87,6 +88,7 @@ internal fun WrapHome( WrapHome( goScan = goScan, goSendConfirmation = goSendConfirmation, + goPaymentRequest = goPaymentRequest, goSettings = goSettings, goMultiTrxSubmissionFailure = goMultiTrxSubmissionFailure, isKeepScreenOnWhileSyncing = isKeepScreenOnWhileSyncing, @@ -105,6 +107,7 @@ internal fun WrapHome( goMultiTrxSubmissionFailure: () -> Unit, goScan: () -> Unit, goSendConfirmation: (ZecSend) -> Unit, + goPaymentRequest: (ZecSend, String) -> Unit, isKeepScreenOnWhileSyncing: Boolean?, isShowingRestoreSuccess: Boolean, sendArguments: SendArguments, @@ -182,6 +185,7 @@ internal fun WrapHome( } }, goSendConfirmation = goSendConfirmation, + goPaymentRequest = goPaymentRequest, goSettings = goSettings, sendArguments = sendArguments ) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/AndroidPaymentRequest.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/AndroidPaymentRequest.kt new file mode 100644 index 00000000..3cd14d65 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/AndroidPaymentRequest.kt @@ -0,0 +1,130 @@ +@file:Suppress("ktlint:standard:filename") + +package co.electriccoin.zcash.ui.screen.paymentrequest + +import androidx.activity.compose.BackHandler +import androidx.compose.material3.SnackbarHostState +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.saveable.rememberSaveable +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import cash.z.ecc.android.sdk.model.Proposal +import co.electriccoin.zcash.di.koinActivityViewModel +import co.electriccoin.zcash.ui.MainActivity +import co.electriccoin.zcash.ui.NavigationTargets +import co.electriccoin.zcash.ui.NavigationTargets.HOME +import co.electriccoin.zcash.ui.R +import co.electriccoin.zcash.ui.common.compose.LocalActivity +import co.electriccoin.zcash.ui.common.compose.LocalNavController +import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel +import co.electriccoin.zcash.ui.screen.authentication.AuthenticationUseCase +import co.electriccoin.zcash.ui.screen.authentication.WrapAuthentication +import co.electriccoin.zcash.ui.screen.contact.AddContactArgs +import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestArguments +import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestStage +import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestState +import co.electriccoin.zcash.ui.screen.paymentrequest.view.PaymentRequestView +import co.electriccoin.zcash.ui.screen.paymentrequest.viewmodel.PaymentRequestViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +internal fun WrapPaymentRequest(arguments: PaymentRequestArguments) { + val activity = LocalActivity.current as MainActivity + val navController = LocalNavController.current + + val walletViewModel = koinActivityViewModel() + val walletState by walletViewModel.walletStateInformation.collectAsStateWithLifecycle() + + val paymentRequestViewModel = koinViewModel { parametersOf(arguments) } + val paymentRequestState by paymentRequestViewModel.state.collectAsStateWithLifecycle() + + val authenticateForProposal = rememberSaveable { mutableStateOf(null) } + + val snackbarHostState = remember { SnackbarHostState() } + + val onBackAction = { + when (paymentRequestState) { + PaymentRequestState.Loading -> {} + is PaymentRequestState.Prepared -> { + val state = (paymentRequestState as PaymentRequestState.Prepared) + when (state.stage) { + PaymentRequestStage.Initial, + PaymentRequestStage.Confirmed -> navController.popBackStack() + PaymentRequestStage.Sending -> { + // No action - wait until the sending is done + } + is PaymentRequestStage.Failure -> paymentRequestViewModel.setStage(PaymentRequestStage.Initial) + is PaymentRequestStage.FailureGrpc -> { + paymentRequestViewModel.setStage(PaymentRequestStage.Confirmed) + navController.navigate(HOME) + } + } + } + } + } + + BackHandler { onBackAction() } + + LaunchedEffect(Unit) { + paymentRequestViewModel.backNavigationCommand.collect { + onBackAction() + } + } + LaunchedEffect(Unit) { + paymentRequestViewModel.closeNavigationCommand.collect { + navController.popBackStack() + } + } + LaunchedEffect(Unit) { + paymentRequestViewModel.addContactNavigationCommand.collect { + navController.navigate(AddContactArgs(it)) + } + } + LaunchedEffect(Unit) { + paymentRequestViewModel.authenticationNavigationCommand.collect { + authenticateForProposal.value = it + } + } + LaunchedEffect(Unit) { + paymentRequestViewModel.homeNavigationCommand.collect { + navController.navigate(HOME) + } + } + LaunchedEffect(Unit) { + paymentRequestViewModel.sendReportFailedNavigationCommand.collect { + snackbarHostState.showSnackbar( + message = activity.getString(R.string.payment_request_send_failed_report_unable_open_email) + ) + } + } + + PaymentRequestView( + state = paymentRequestState, + topAppBarSubTitleState = walletState, + snackbarHostState = snackbarHostState + ) + + if (authenticateForProposal.value != null) { + activity.WrapAuthentication( + goSupport = { + authenticateForProposal.value = null + navController.navigate(NavigationTargets.SUPPORT) + }, + onSuccess = { + paymentRequestViewModel.onSendAllowed(authenticateForProposal.value!!) + authenticateForProposal.value = null + }, + onCancel = { + authenticateForProposal.value = null + }, + onFailed = { + // No action needed + }, + useCase = AuthenticationUseCase.SendFunds + ) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/PaymentRequestArgumentsFixture.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/PaymentRequestArgumentsFixture.kt new file mode 100644 index 00000000..7176644a --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/PaymentRequestArgumentsFixture.kt @@ -0,0 +1,23 @@ +package co.electriccoin.zcash.ui.screen.paymentrequest + +import cash.z.ecc.android.sdk.fixture.WalletFixture +import cash.z.ecc.android.sdk.model.FirstClassByteArray +import cash.z.ecc.android.sdk.model.ZcashNetwork +import cash.z.ecc.android.sdk.type.AddressType +import co.electriccoin.zcash.ui.common.model.SerializableAddress +import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestArguments + +internal object PaymentRequestArgumentsFixture { + fun new() = + PaymentRequestArguments( + address = + SerializableAddress( + WalletFixture.Alice.getAddresses(ZcashNetwork.Mainnet).unified, + AddressType.Unified + ), + amount = 10000000, + memo = "For the coffee", + proposal = FirstClassByteArray(byteArrayOf()), + zip321Uri = "zcash:t1duiEGg7b39nfQee3XaTY4f5McqfyJKhBi?amount=1&memo=VGhpcyBpcyBhIHNpbXBsZSBt", + ) +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/model/PaymentRequestArguments.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/model/PaymentRequestArguments.kt new file mode 100644 index 00000000..c3f96c45 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/model/PaymentRequestArguments.kt @@ -0,0 +1,51 @@ +package co.electriccoin.zcash.ui.screen.paymentrequest.model + +import androidx.lifecycle.SavedStateHandle +import cash.z.ecc.android.sdk.model.FirstClassByteArray +import cash.z.ecc.android.sdk.model.Memo +import cash.z.ecc.android.sdk.model.Proposal +import cash.z.ecc.android.sdk.model.Zatoshi +import cash.z.ecc.android.sdk.model.ZecSend +import co.electriccoin.zcash.ui.NavigationArguments +import co.electriccoin.zcash.ui.common.model.SerializableAddress +import kotlinx.serialization.json.Json + +data class PaymentRequestArguments( + val address: SerializableAddress?, + val amount: Long?, + val memo: String?, + val proposal: FirstClassByteArray?, + val zip321Uri: String?, +) { + companion object { + internal fun fromSavedStateHandle(savedStateHandle: SavedStateHandle) = + PaymentRequestArguments( + address = + savedStateHandle.get(NavigationArguments.PAYMENT_REQUEST_ADDRESS)?.let { + Json.decodeFromString(it) + }, + amount = savedStateHandle.get(NavigationArguments.PAYMENT_REQUEST_AMOUNT), + memo = savedStateHandle.get(NavigationArguments.PAYMENT_REQUEST_MEMO), + proposal = + savedStateHandle.get(NavigationArguments.PAYMENT_REQUEST_PROPOSAL)?.let { + FirstClassByteArray(it) + }, + zip321Uri = savedStateHandle.get(NavigationArguments.PAYMENT_REQUEST_URI), + ).also { + // Remove the screen arguments passed from the other screen if some exist + savedStateHandle.remove(NavigationArguments.PAYMENT_REQUEST_ADDRESS) + savedStateHandle.remove(NavigationArguments.PAYMENT_REQUEST_AMOUNT) + savedStateHandle.remove(NavigationArguments.PAYMENT_REQUEST_MEMO) + savedStateHandle.remove(NavigationArguments.PAYMENT_REQUEST_PROPOSAL) + savedStateHandle.remove(NavigationArguments.PAYMENT_REQUEST_URI) + } + } + + internal fun toZecSend() = + ZecSend( + destination = address?.toWalletAddress() ?: error("Address null"), + amount = amount?.let { Zatoshi(amount) } ?: error("Amount null"), + memo = memo?.let { Memo(memo) } ?: error("Memo null"), + proposal = proposal?.let { Proposal.fromByteArray(proposal.byteArray) } ?: error("Proposal null"), + ) +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/model/PaymentRequestStage.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/model/PaymentRequestStage.kt new file mode 100644 index 00000000..ccd8795e --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/model/PaymentRequestStage.kt @@ -0,0 +1,16 @@ +package co.electriccoin.zcash.ui.screen.paymentrequest.model + +sealed class PaymentRequestStage { + data object Initial : PaymentRequestStage() + + data object Sending : PaymentRequestStage() + + data object Confirmed : PaymentRequestStage() + + data class Failure( + val error: String, + val stackTrace: String, + ) : PaymentRequestStage() + + data object FailureGrpc : PaymentRequestStage() +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/model/PaymentRequestState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/model/PaymentRequestState.kt new file mode 100644 index 00000000..9a253779 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/model/PaymentRequestState.kt @@ -0,0 +1,25 @@ +package co.electriccoin.zcash.ui.screen.paymentrequest.model + +import cash.z.ecc.android.sdk.model.MonetarySeparators +import cash.z.ecc.android.sdk.model.Proposal +import cash.z.ecc.android.sdk.model.ZecSend +import co.electriccoin.zcash.ui.common.model.AddressBookContact +import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState + +internal sealed class PaymentRequestState { + data object Loading : PaymentRequestState() + + data class Prepared( + val arguments: PaymentRequestArguments, + val contact: AddressBookContact?, + val exchangeRateState: ExchangeRateState, + val monetarySeparators: MonetarySeparators, + val onAddToContacts: (String) -> Unit, + val onClose: () -> Unit, + val onBack: () -> Unit, + val onSend: (proposal: Proposal) -> Unit, + val zecSend: ZecSend, + val stage: PaymentRequestStage, + val onContactSupport: (String?) -> Unit, + ) : PaymentRequestState() +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/view/PaymentRequestView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/view/PaymentRequestView.kt new file mode 100644 index 00000000..8dce3491 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/view/PaymentRequestView.kt @@ -0,0 +1,545 @@ +@file:Suppress("TooManyFunctions") + +package co.electriccoin.zcash.ui.screen.paymentrequest.view + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.IconButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import cash.z.ecc.android.sdk.model.MonetarySeparators +import cash.z.ecc.android.sdk.model.WalletAddress +import cash.z.ecc.sdk.extension.toZecStringFull +import co.electriccoin.zcash.ui.R +import co.electriccoin.zcash.ui.common.compose.BalanceWidgetBigLineOnly +import co.electriccoin.zcash.ui.common.extension.asZecAmountTriple +import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState +import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState +import co.electriccoin.zcash.ui.design.component.AppAlertDialog +import co.electriccoin.zcash.ui.design.component.BlankBgScaffold +import co.electriccoin.zcash.ui.design.component.BlankSurface +import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator +import co.electriccoin.zcash.ui.design.component.StyledBalance +import co.electriccoin.zcash.ui.design.component.StyledBalanceDefaults +import co.electriccoin.zcash.ui.design.component.ZashiBottomBar +import co.electriccoin.zcash.ui.design.component.ZashiButton +import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar +import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens +import co.electriccoin.zcash.ui.design.theme.ZcashTheme +import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors +import co.electriccoin.zcash.ui.design.theme.dimensions.ZashiDimensions +import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography +import co.electriccoin.zcash.ui.design.util.scaffoldPadding +import co.electriccoin.zcash.ui.screen.exchangerate.widget.StyledExchangeLabel +import co.electriccoin.zcash.ui.screen.paymentrequest.PaymentRequestArgumentsFixture +import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestStage +import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestState +import co.electriccoin.zcash.ui.screen.send.ext.abbreviated + +@Composable +@PreviewScreens +private fun PaymentRequestLoadingPreview() = + ZcashTheme(forceDarkMode = true) { + PaymentRequestView( + state = PaymentRequestState.Loading, + topAppBarSubTitleState = TopAppBarSubTitleState.None, + snackbarHostState = SnackbarHostState(), + ) + } + +@Composable +@PreviewScreens +private fun PaymentRequestPreview() = + ZcashTheme(forceDarkMode = false) { + PaymentRequestView( + state = + PaymentRequestState.Prepared( + arguments = PaymentRequestArgumentsFixture.new(), + contact = null, + exchangeRateState = ExchangeRateState.Data(onRefresh = {}), + monetarySeparators = MonetarySeparators.current(), + onAddToContacts = {}, + onContactSupport = { _ -> }, + onBack = {}, + onClose = {}, + onSend = {}, + zecSend = PaymentRequestArgumentsFixture.new().toZecSend(), + stage = PaymentRequestStage.Initial, + ), + topAppBarSubTitleState = TopAppBarSubTitleState.None, + snackbarHostState = SnackbarHostState(), + ) + } + +@Composable +internal fun PaymentRequestView( + state: PaymentRequestState, + topAppBarSubTitleState: TopAppBarSubTitleState, + snackbarHostState: SnackbarHostState, +) { + when (state) { + PaymentRequestState.Loading -> { + CircularScreenProgressIndicator() + } + is PaymentRequestState.Prepared -> { + BlankBgScaffold( + topBar = { + PaymentRequestTopAppBar( + onClose = state.onClose, + subTitleState = topAppBarSubTitleState, + ) + }, + bottomBar = { + PaymentRequestBottomBar(state = state) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { paddingValues -> + Box { + PaymentRequestContents( + state = state, + modifier = + Modifier + .fillMaxHeight() + .verticalScroll( + rememberScrollState() + ) + .scaffoldPadding(paddingValues), + ) + when (state.stage) { + PaymentRequestStage.FailureGrpc -> { + PaymentRequestSendFailureGrpc( + onDone = state.onBack + ) + } + is PaymentRequestStage.Failure -> { + PaymentRequestSendFailure( + onDone = state.onBack, + onReport = { status -> state.onContactSupport(status.stackTrace) }, + stage = state.stage, + ) + } + else -> { + // No action needed + } + } + } + } + } + } +} + +@Composable +private fun PaymentRequestTopAppBar( + onClose: () -> Unit, + subTitleState: TopAppBarSubTitleState, +) { + ZashiSmallTopAppBar( + subtitle = + when (subTitleState) { + TopAppBarSubTitleState.Disconnected -> stringResource(id = R.string.disconnected_label) + TopAppBarSubTitleState.Restoring -> stringResource(id = R.string.restoring_wallet_label) + TopAppBarSubTitleState.None -> null + }, + title = stringResource(id = R.string.payment_request_title), + navigationAction = { + IconButton( + onClick = onClose, + modifier = + Modifier + .padding(horizontal = ZcashTheme.dimens.spacingDefault) + // Making the size bigger by 3.dp so the rounded image corners are not stripped out + .size(43.dp), + ) { + Image( + painter = + painterResource( + id = co.electriccoin.zcash.ui.design.R.drawable.ic_close_full + ), + contentDescription = stringResource(id = R.string.payment_request_close_content_description), + modifier = + Modifier + .padding(all = 3.dp) + ) + } + }, + ) +} + +@Composable +private fun PaymentRequestBottomBar( + state: PaymentRequestState.Prepared, + modifier: Modifier = Modifier, +) { + ZashiBottomBar(modifier = modifier.fillMaxWidth()) { + ZashiButton( + text = stringResource(id = R.string.payment_request_send_btn), + onClick = { state.onSend(state.zecSend.proposal!!) }, + enabled = state.stage != PaymentRequestStage.Sending && state.stage != PaymentRequestStage.Confirmed, + isLoading = state.stage == PaymentRequestStage.Sending, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + } +} + +@Composable +private fun PaymentRequestContents( + state: PaymentRequestState.Prepared, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacingXl)) + + PaymentRequestBalances(state) + + Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacing4xl)) + + PaymentRequestAddresses(state) + + if (state.zecSend.memo.value.isNotEmpty()) { + Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacing4xl)) + PaymentRequestMemo(state) + } + + Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacing3xl)) + + PaymentRequestAmounts(state) + } +} + +@Composable +private fun PaymentRequestBalances( + state: PaymentRequestState.Prepared, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BalanceWidgetBigLineOnly( + parts = state.zecSend.amount.toZecStringFull().asZecAmountTriple(), + // We don't hide any balance in confirmation screen + isHideBalances = false + ) + + StyledExchangeLabel( + zatoshi = state.zecSend.amount, + state = state.exchangeRateState, + isHideBalances = false, + style = ZashiTypography.textMd.copy(fontWeight = FontWeight.SemiBold), + textColor = ZashiColors.Text.textPrimary + ) + } +} + +@Composable +private fun PaymentRequestAddresses( + state: PaymentRequestState.Prepared, + modifier: Modifier = Modifier +) { + var isShowingFullAddress by rememberSaveable { + mutableStateOf( + when (state.zecSend.destination) { + is WalletAddress.Transparent -> true + else -> false + } + ) + } + Column(modifier = modifier) { + Text( + text = stringResource(id = R.string.payment_request_requested_by), + color = ZashiColors.Text.textTertiary, + style = ZashiTypography.textSm, + fontWeight = FontWeight.Medium + ) + + if (state.contact != null) { + Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacingSm)) + Text( + text = state.contact.name, + color = ZashiColors.Inputs.Filled.label, + style = ZashiTypography.textSm, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacingSm)) + + Text( + text = + if (isShowingFullAddress) { + state.zecSend.destination.address + } else { + state.zecSend.destination.abbreviated() + }, + color = ZashiColors.Text.textPrimary, + style = ZashiTypography.textXs, + fontWeight = FontWeight.Normal, + modifier = Modifier.animateContentSize() + ) + + Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacingXl)) + + Row( + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + if (state.zecSend.destination !is WalletAddress.Transparent) { + if (isShowingFullAddress) { + PaymentRequestChipText( + text = stringResource(id = R.string.payment_request_btn_hide_address), + icon = painterResource(id = R.drawable.ic_chevron_up), + onClick = { isShowingFullAddress = false } + ) + } else { + PaymentRequestChipText( + text = stringResource(id = R.string.payment_request_btn_show_address), + icon = painterResource(id = R.drawable.ic_chevron_down), + onClick = { isShowingFullAddress = true } + ) + } + Spacer(modifier = Modifier.width(ZashiDimensions.Spacing.spacingLg)) + } + if (state.contact == null) { + PaymentRequestChipText( + text = stringResource(id = R.string.payment_request_btn_save_contact), + icon = painterResource(id = R.drawable.ic_user_plus), + onClick = { state.onAddToContacts(state.zecSend.destination.address) } + ) + } + } + } +} + +@Composable +private fun PaymentRequestChipText( + text: String, + icon: Painter, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = + modifier + .background( + ZashiColors.Btns.Tertiary.btnTertiaryBg, + RoundedCornerShape(ZashiDimensions.Radius.radiusMd) + ) + .clip(RoundedCornerShape(ZashiDimensions.Radius.radiusMd)) + .clickable { onClick() } + .padding( + horizontal = ZashiDimensions.Spacing.spacingXl, + vertical = 10.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(ZashiColors.Btns.Tertiary.btnTertiaryFg) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = text, + color = ZashiColors.Btns.Tertiary.btnTertiaryFg, + style = ZashiTypography.textSm, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +private fun PaymentRequestMemo( + state: PaymentRequestState.Prepared, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(id = R.string.payment_request_memo), + color = ZashiColors.Text.textPrimary, + style = ZashiTypography.textSm, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacingSm)) + + Box( + modifier = + Modifier + .fillMaxWidth() + .background(ZashiColors.Inputs.Filled.bg, RoundedCornerShape(ZashiDimensions.Radius.radiusIg)) + .padding(all = ZashiDimensions.Spacing.spacingXl), + ) { + Text( + text = state.zecSend.memo.value, + color = ZashiColors.Inputs.Filled.text, + style = ZashiTypography.textXs, + fontWeight = FontWeight.Medium + ) + } + } +} + +@Composable +private fun PaymentRequestAmounts( + state: PaymentRequestState.Prepared, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = R.string.payment_request_fee), + color = ZashiColors.Text.textTertiary, + style = ZashiTypography.textSm, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.width(ZashiDimensions.Spacing.spacingMd)) + StyledBalance( + balanceParts = state.zecSend.proposal!!.totalFeeRequired().toZecStringFull().asZecAmountTriple(), + textColor = ZashiColors.Text.textPrimary, + textStyle = + StyledBalanceDefaults.textStyles( + mostSignificantPart = ZashiTypography.textSm.copy(fontWeight = FontWeight.SemiBold), + leastSignificantPart = ZashiTypography.textXxs.copy(fontWeight = FontWeight.SemiBold), + ) + ) + } + + Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacing2xl)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = R.string.payment_request_total), + color = ZashiColors.Text.textTertiary, + style = ZashiTypography.textSm, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.width(ZashiDimensions.Spacing.spacingMd)) + StyledBalance( + balanceParts = state.zecSend.amount.toZecStringFull().asZecAmountTriple(), + textColor = ZashiColors.Text.textPrimary, + textStyle = + StyledBalanceDefaults.textStyles( + mostSignificantPart = ZashiTypography.textSm.copy(fontWeight = FontWeight.SemiBold), + leastSignificantPart = ZashiTypography.textXxs.copy(fontWeight = FontWeight.SemiBold), + ) + ) + } + } +} + +@Composable +@Preview("SendConfirmationFailure") +private fun PreviewSendConfirmationFailure() { + ZcashTheme(forceDarkMode = false) { + BlankSurface { + PaymentRequestSendFailure( + onDone = {}, + onReport = {}, + stage = PaymentRequestStage.Failure("Failed - network error", "Failed stackTrace"), + ) + } + } +} + +@Composable +private fun PaymentRequestSendFailure( + onDone: () -> Unit, + onReport: (PaymentRequestStage.Failure) -> Unit, + stage: PaymentRequestStage.Failure, +) { + // TODO [#1276]: Once we ensure that the reason contains a localized message, we can leverage it for the UI prompt + // TODO [#1276]: Consider adding support for a specific exception in AppAlertDialog + // TODO [#1276]: https://github.com/Electric-Coin-Company/zashi-android/issues/1276 + + AppAlertDialog( + title = stringResource(id = R.string.payment_request_dialog_error_title), + text = { + Column( + Modifier.verticalScroll(rememberScrollState()) + ) { + Text( + text = stringResource(id = R.string.payment_request_dialog_error_text), + color = ZcashTheme.colors.textPrimary, + ) + + if (stage.error.isNotEmpty()) { + Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) + + Text( + text = stage.error, + fontStyle = FontStyle.Italic, + color = ZcashTheme.colors.textPrimary, + ) + } + } + }, + confirmButtonText = stringResource(id = R.string.payment_request_dialog_error_ok_btn), + onConfirmButtonClick = onDone, + dismissButtonText = stringResource(id = R.string.payment_request_dialog_error_report_btn), + onDismissButtonClick = { onReport(stage) }, + ) +} + +@Composable +private fun PaymentRequestSendFailureGrpc(onDone: () -> Unit) { + AppAlertDialog( + title = stringResource(id = R.string.payment_request_dialog_error_grpc_title), + text = { + Column( + Modifier.verticalScroll(rememberScrollState()) + ) { + Text( + text = stringResource(id = R.string.payment_request_dialog_error_grpc_text), + color = ZcashTheme.colors.textPrimary, + ) + } + }, + confirmButtonText = stringResource(id = R.string.payment_request_dialog_error_grpc_btn), + onConfirmButtonClick = onDone + ) +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/viewmodel/PaymentRequestViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/viewmodel/PaymentRequestViewModel.kt new file mode 100644 index 00000000..d1d991fe --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/paymentrequest/viewmodel/PaymentRequestViewModel.kt @@ -0,0 +1,239 @@ +package co.electriccoin.zcash.ui.screen.paymentrequest.viewmodel + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import cash.z.ecc.android.sdk.SdkSynchronizer +import cash.z.ecc.android.sdk.Synchronizer +import cash.z.ecc.android.sdk.model.Proposal +import cash.z.ecc.android.sdk.model.UnifiedSpendingKey +import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT +import co.electriccoin.zcash.spackle.Twig +import co.electriccoin.zcash.ui.R +import co.electriccoin.zcash.ui.common.provider.GetMonetarySeparatorProvider +import co.electriccoin.zcash.ui.common.usecase.GetSpendingKeyUseCase +import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase +import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase +import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel +import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel +import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestArguments +import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestStage +import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestState +import co.electriccoin.zcash.ui.screen.sendconfirmation.model.SubmitResult +import co.electriccoin.zcash.ui.screen.sendconfirmation.viewmodel.CreateTransactionsViewModel +import co.electriccoin.zcash.ui.screen.support.model.SupportInfo +import co.electriccoin.zcash.ui.screen.support.model.SupportInfoType +import co.electriccoin.zcash.ui.screen.support.viewmodel.SupportViewModel +import co.electriccoin.zcash.ui.util.EmailUtil +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@Suppress("TooManyFunctions") +class PaymentRequestViewModel( + private val application: Application, + private val arguments: PaymentRequestArguments, + private val authenticationViewModel: AuthenticationViewModel, + private val createTransactionsViewModel: CreateTransactionsViewModel, + getMonetarySeparators: GetMonetarySeparatorProvider, + private val getSpendingKeyUseCase: GetSpendingKeyUseCase, + private val getSynchronizer: GetSynchronizerUseCase, + private val supportViewModel: SupportViewModel, + walletViewModel: WalletViewModel, + observeAddressBookContacts: ObserveAddressBookContactsUseCase, +) : ViewModel() { + private val stage = MutableStateFlow(PaymentRequestStage.Initial) + + internal val state = + combine( + walletViewModel.exchangeRateUsd, + observeAddressBookContacts(), + stage, + supportViewModel.supportInfo.mapNotNull { it }, + ) { rate, contacts, currentStage, supportInfo -> + PaymentRequestState.Prepared( + arguments = arguments, + contact = contacts?.find { it.address == arguments.address?.address }, + exchangeRateState = rate, + monetarySeparators = getMonetarySeparators(), + onAddToContacts = { onAddToContacts(it) }, + onBack = ::onBack, + onClose = ::onClose, + onContactSupport = { message -> onContactSupport(message, supportInfo) }, + onSend = { onSend(it) }, + stage = currentStage, + zecSend = arguments.toZecSend(), + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), + initialValue = PaymentRequestState.Loading + ) + + internal val backNavigationCommand = MutableSharedFlow() + + internal val closeNavigationCommand = MutableSharedFlow() + + internal val addContactNavigationCommand = MutableSharedFlow() + + internal val homeNavigationCommand = MutableSharedFlow() + + internal val authenticationNavigationCommand = MutableSharedFlow() + + internal val sendReportFailedNavigationCommand = MutableSharedFlow() + + internal fun onClose() = + viewModelScope.launch { + closeNavigationCommand.emit(Unit) + } + + internal fun onBack() = + viewModelScope.launch { + backNavigationCommand.emit(Unit) + } + + private fun onHome() = + viewModelScope.launch { + homeNavigationCommand.emit(Unit) + } + + internal fun setStage(newStage: PaymentRequestStage) = + viewModelScope.launch { + stage.emit(newStage) + } + + private fun onAddToContacts(address: String) = + viewModelScope.launch { + addContactNavigationCommand.emit(address) + } + + private fun onSend(proposal: Proposal) = + viewModelScope.launch { + authenticationViewModel.isSendFundsAuthenticationRequired + .filterNotNull() + .collect { isProtected -> + if (isProtected) { + authenticationNavigationCommand.emit(proposal) + } else { + onSendAllowed(proposal) + } + } + } + + internal fun onSendAllowed(proposal: Proposal) = + viewModelScope.launch { + runSendFundsAction( + createTransactionsViewModel = createTransactionsViewModel, + // The not-null assertion operator is necessary here even if we check its + // nullability before due to property is declared in different module. See more + // details on the Kotlin forum + proposal = proposal, + spendingKey = getSpendingKeyUseCase(), + synchronizer = getSynchronizer(), + ) + } + + private suspend fun runSendFundsAction( + createTransactionsViewModel: CreateTransactionsViewModel, + proposal: Proposal, + spendingKey: UnifiedSpendingKey, + synchronizer: Synchronizer, + ) { + setStage(PaymentRequestStage.Sending) + + val submitResult = + submitTransactions( + createTransactionsViewModel = createTransactionsViewModel, + proposal = proposal, + synchronizer = synchronizer, + spendingKey = spendingKey + ) + + Twig.debug { "Transactions submitted with result: $submitResult" } + + processSubmissionResult(submitResult = submitResult) + } + + private suspend fun submitTransactions( + createTransactionsViewModel: CreateTransactionsViewModel, + proposal: Proposal, + synchronizer: Synchronizer, + spendingKey: UnifiedSpendingKey + ): SubmitResult { + Twig.debug { "Sending transactions..." } + + val result = + createTransactionsViewModel.runCreateTransactions( + synchronizer = synchronizer, + spendingKey = spendingKey, + proposal = proposal + ) + + // Triggering the transaction history and balances refresh to be notified immediately + // about the wallet's updated state + (synchronizer as SdkSynchronizer).run { + refreshTransactions() + refreshAllBalances() + } + + return result + } + + private fun processSubmissionResult(submitResult: SubmitResult) { + when (submitResult) { + SubmitResult.Success -> { + setStage(PaymentRequestStage.Confirmed) + onHome() + } + is SubmitResult.SimpleTrxFailure.SimpleTrxFailureSubmit -> { + setStage(PaymentRequestStage.Failure(submitResult.toErrorMessage(), submitResult.toErrorStacktrace())) + } + is SubmitResult.SimpleTrxFailure.SimpleTrxFailureGrpc -> { + setStage(PaymentRequestStage.FailureGrpc) + } + is SubmitResult.SimpleTrxFailure.SimpleTrxFailureOther -> { + setStage(PaymentRequestStage.Failure(submitResult.toErrorMessage(), submitResult.toErrorStacktrace())) + } + is SubmitResult.MultipleTrxFailure -> { + Twig.error { "$submitResult is currently unsupported submission result" } + } + } + } + + private fun onContactSupport( + message: String?, + supportInfo: SupportInfo + ) = viewModelScope.launch { + val fullMessage = + EmailUtil.formatMessage( + body = message, + supportInfo = + supportInfo.toSupportString( + SupportInfoType.entries.toSet() + ) + ) + val mailIntent = + EmailUtil.newMailActivityIntent( + application.applicationContext.getString(R.string.support_email_address), + application.applicationContext.getString(R.string.app_name), + fullMessage + ).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + runCatching { + application.startActivity(mailIntent) + }.onSuccess { + setStage(PaymentRequestStage.Initial) + }.onFailure { + setStage(PaymentRequestStage.Initial) + sendReportFailedNavigationCommand.tryEmit(Unit) + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/qrcode/view/QrCodeView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/qrcode/view/QrCodeView.kt index 344b1e39..3ad2287a 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/qrcode/view/QrCodeView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/qrcode/view/QrCodeView.kt @@ -6,10 +6,12 @@ import androidx.compose.animation.animateContentSize import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -435,6 +437,14 @@ private fun ColumnScope.QrCode( ), shape = RoundedCornerShape(ZashiDimensions.Radius.radius4xl) ) + .background( + if (isSystemInDarkTheme()) { + ZashiColors.Surfaces.bgAlt + } else { + ZashiColors.Surfaces.bgPrimary + }, + RoundedCornerShape(ZashiDimensions.Radius.radius4xl) + ) .padding(all = 12.dp) ) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestQrCodeView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestQrCodeView.kt index 81f982f5..4355fd84 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestQrCodeView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestQrCodeView.kt @@ -2,9 +2,11 @@ package co.electriccoin.zcash.ui.screen.request.view import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -133,6 +135,14 @@ private fun ColumnScope.QrCode( ), shape = RoundedCornerShape(ZashiDimensions.Radius.radius4xl) ) + .background( + if (isSystemInDarkTheme()) { + ZashiColors.Surfaces.bgAlt + } else { + ZashiColors.Surfaces.bgPrimary + }, + RoundedCornerShape(ZashiDimensions.Radius.radius4xl) + ) .padding(all = 12.dp) ) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt index 0ab80367..727871f8 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt @@ -3,68 +3,62 @@ package co.electriccoin.zcash.ui.screen.scan import androidx.activity.compose.BackHandler import androidx.compose.material3.SnackbarHostState 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.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle -import cash.z.ecc.android.sdk.Synchronizer -import cash.z.ecc.android.sdk.type.AddressType import co.electriccoin.zcash.di.koinActivityViewModel +import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_RECIPIENT_ADDRESS +import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_ZIP_321_URI import co.electriccoin.zcash.ui.R -import co.electriccoin.zcash.ui.common.model.SerializableAddress -import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState +import co.electriccoin.zcash.ui.common.compose.LocalNavController import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator +import co.electriccoin.zcash.ui.popBackStackJustOnce +import co.electriccoin.zcash.ui.screen.scan.model.ScanResultState import co.electriccoin.zcash.ui.screen.scan.view.Scan +import co.electriccoin.zcash.ui.screen.scan.viewmodel.ScanViewModel import co.electriccoin.zcash.ui.util.SettingsUtil import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf @Composable -internal fun WrapScanValidator( - onScanValid: (address: SerializableAddress) -> Unit, - goBack: () -> Unit -) { +internal fun WrapScanValidator(args: ScanNavigationArgs) { + val navController = LocalNavController.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } val walletViewModel = koinActivityViewModel() - + val viewModel = koinViewModel { parametersOf(args) } val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value - val walletState = walletViewModel.walletStateInformation.collectAsStateWithLifecycle().value + val state by viewModel.state.collectAsStateWithLifecycle() BackHandler { - goBack() + navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE) } - WrapScan( - onScanValid = onScanValid, - goBack = goBack, - synchronizer = synchronizer, - topAppBarSubTitleState = walletState, - ) -} + LaunchedEffect(Unit) { + viewModel.navigateBack.collect { scanResult -> + navController.previousBackStackEntry?.savedStateHandle?.apply { + when (scanResult) { + is ScanResultState.Address -> set(SEND_SCAN_RECIPIENT_ADDRESS, scanResult.address) + is ScanResultState.Zip321Uri -> set(SEND_SCAN_ZIP_321_URI, scanResult.zip321Uri) + } + } + navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE) + } + } -@Composable -fun WrapScan( - goBack: () -> Unit, - onScanValid: (address: SerializableAddress) -> Unit, - synchronizer: Synchronizer?, - topAppBarSubTitleState: TopAppBarSubTitleState, -) { - val context = LocalContext.current - - val scope = rememberCoroutineScope() - - val snackbarHostState = remember { SnackbarHostState() } - - var hasBeenScannedSuccessfully by remember { mutableStateOf(false) } - - val mutex = remember { Mutex() } - - var addressValidationResult by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + viewModel.navigateCommand.collect { + navController.popBackStack() + navController.navigate(it) + } + } if (synchronizer == null) { // TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer @@ -74,24 +68,13 @@ fun WrapScan( } else { Scan( snackbarHostState = snackbarHostState, - addressValidationResult = addressValidationResult, - onBack = goBack, - onScanned = { result -> - scope.launch { - mutex.withLock { - if (!hasBeenScannedSuccessfully) { - addressValidationResult = synchronizer.validateAddress(result) - val isAddressValid = addressValidationResult?.let { !it.isNotValid } ?: false - if (isAddressValid) { - hasBeenScannedSuccessfully = true - onScanValid(SerializableAddress(result, addressValidationResult!!)) - } - } - } - } + validationResult = state, + onBack = { navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE) }, + onScanned = { + viewModel.onScanned(it) }, onScanError = { - addressValidationResult = AddressType.Invalid() + viewModel.onScannedError() }, onOpenSettings = { runCatching { @@ -107,7 +90,7 @@ fun WrapScan( } }, onScanStateChanged = {}, - topAppBarSubTitleState = topAppBarSubTitleState, + topAppBarSubTitleState = walletState, ) } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanResultState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanResultState.kt new file mode 100644 index 00000000..e0597eb1 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanResultState.kt @@ -0,0 +1,7 @@ +package co.electriccoin.zcash.ui.screen.scan.model + +sealed class ScanResultState { + data class Address(val address: String) : ScanResultState() + + data class Zip321Uri(val zip321Uri: String) : ScanResultState() +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanScreenState.kt similarity index 76% rename from ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanState.kt rename to ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanScreenState.kt index 9ed2fafd..a28e5687 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanScreenState.kt @@ -1,6 +1,6 @@ package co.electriccoin.zcash.ui.screen.scan.model -enum class ScanState { +enum class ScanScreenState { Failed, Permission, Scanning diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanValidationState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanValidationState.kt new file mode 100644 index 00000000..cbd07172 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/model/ScanValidationState.kt @@ -0,0 +1,7 @@ +package co.electriccoin.zcash.ui.screen.scan.model + +enum class ScanValidationState { + NONE, + INVALID, + VALID +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/view/ScanView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/view/ScanView.kt index c2167707..3cbf5c47 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/view/ScanView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/view/ScanView.kt @@ -74,7 +74,6 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import androidx.core.content.ContextCompat -import cash.z.ecc.android.sdk.type.AddressType import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState @@ -86,7 +85,8 @@ import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography import co.electriccoin.zcash.ui.screen.scan.ScanTag -import co.electriccoin.zcash.ui.screen.scan.model.ScanState +import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState +import co.electriccoin.zcash.ui.screen.scan.model.ScanValidationState import co.electriccoin.zcash.ui.screen.scan.util.ImageUriToQrCodeConverter import co.electriccoin.zcash.ui.screen.scan.util.QrCodeAnalyzer import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -111,9 +111,9 @@ fun Scan( onScanned: (String) -> Unit, onScanError: () -> Unit, onOpenSettings: () -> Unit, - onScanStateChanged: (ScanState) -> Unit, + onScanStateChanged: (ScanScreenState) -> Unit, topAppBarSubTitleState: TopAppBarSubTitleState, - addressValidationResult: AddressType? + validationResult: ScanValidationState ) = ZcashTheme(forceDarkMode = true) { // forces dark theme for this screen val permissionState = if (LocalInspectionMode.current) { @@ -134,15 +134,15 @@ fun Scan( val (scanState, setScanState) = if (LocalInspectionMode.current) { remember { - mutableStateOf(ScanState.Scanning) + mutableStateOf(ScanScreenState.Scanning) } } else { rememberSaveable { mutableStateOf( if (permissionState.status.isGranted) { - ScanState.Scanning + ScanScreenState.Scanning } else { - ScanState.Permission + ScanScreenState.Permission } ) } @@ -153,7 +153,7 @@ fun Scan( ) { _ -> Box { ScanMainContent( - addressValidationResult = addressValidationResult, + validationResult = validationResult, onScanned = onScanned, onScanError = onScanError, onOpenSettings = onOpenSettings, @@ -166,7 +166,7 @@ fun Scan( Modifier .fillMaxSize() .background( - if (scanState != ScanState.Scanning) { + if (scanState != ScanScreenState.Scanning) { ZcashTheme.colors.cameraDisabledBackgroundColor } else { Color.Black @@ -177,7 +177,7 @@ fun Scan( ScanTopAppBar( onBack = onBack, - showBack = scanState != ScanState.Scanning, + showBack = scanState != ScanScreenState.Scanning, subTitleState = topAppBarSubTitleState, ) } @@ -186,8 +186,8 @@ fun Scan( @Composable fun ScanBottomItems( - addressValidationResult: AddressType?, - scanState: ScanState, + validationResult: ScanValidationState, + scanState: ScanScreenState, onOpenSettings: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -195,22 +195,21 @@ fun ScanBottomItems( Column(modifier) { var failureText: String? = null - // Check validation result, if any - if (addressValidationResult is AddressType.Invalid) { + if (validationResult == ScanValidationState.INVALID) { failureText = stringResource(id = R.string.scan_address_validation_failed) } // Check permission request result, if any failureText = when (scanState) { - ScanState.Permission -> + ScanScreenState.Permission -> stringResource( id = R.string.scan_state_permission, stringResource(id = R.string.app_name) ) - ScanState.Failed -> stringResource(id = R.string.scan_state_failed) - ScanState.Scanning -> failureText + ScanScreenState.Failed -> stringResource(id = R.string.scan_state_failed) + ScanScreenState.Scanning -> failureText } if (failureText != null) { @@ -236,7 +235,7 @@ fun ScanBottomItems( Spacer(modifier = Modifier.height(24.dp)) when (scanState) { - ScanState.Scanning, ScanState.Failed -> { + ScanScreenState.Scanning, ScanScreenState.Failed -> { ZashiButton( modifier = Modifier.fillMaxWidth(), onClick = onBack, @@ -244,7 +243,7 @@ fun ScanBottomItems( ) } - ScanState.Permission -> { + ScanScreenState.Permission -> { ZashiButton( modifier = Modifier.fillMaxWidth(), onClick = onOpenSettings, @@ -306,20 +305,20 @@ data class FramePosition( ) @Composable private fun ScanMainContent( - addressValidationResult: AddressType?, + validationResult: ScanValidationState, onScanned: (String) -> Unit, onScanError: () -> Unit, onOpenSettings: () -> Unit, onBack: () -> Unit, - onScanStateChanged: (ScanState) -> Unit, + onScanStateChanged: (ScanScreenState) -> Unit, permissionState: PermissionState, - scanState: ScanState, - setScanState: (ScanState) -> Unit, + scanState: ScanScreenState, + setScanState: (ScanScreenState) -> Unit, modifier: Modifier = Modifier, ) { when { (!permissionState.status.isGranted) -> { - setScanState(ScanState.Permission) + setScanState(ScanScreenState.Permission) if (permissionState.status.shouldShowRationale) { // Keep dark screen with a link to the app settings - user denied the permission previously } else { @@ -329,13 +328,13 @@ private fun ScanMainContent( } } - (scanState == ScanState.Failed) -> { + (scanState == ScanScreenState.Failed) -> { // Keep current state } (permissionState.status.isGranted) -> { - if (scanState != ScanState.Scanning) { - setScanState(ScanState.Scanning) + if (scanState != ScanScreenState.Scanning) { + setScanState(ScanScreenState.Scanning) } } } @@ -411,13 +410,13 @@ private fun ScanMainContent( val (frame, frameWindow, bottomItems, topAnchor) = createRefs() when (scanState) { - ScanState.Permission -> { + ScanScreenState.Permission -> { // Keep initial ui state - onScanStateChanged(ScanState.Permission) + onScanStateChanged(ScanScreenState.Permission) } - ScanState.Scanning -> { - onScanStateChanged(ScanState.Scanning) + ScanScreenState.Scanning -> { + onScanStateChanged(ScanScreenState.Scanning) if (!LocalInspectionMode.current) { ScanCameraView( @@ -487,8 +486,8 @@ private fun ScanMainContent( } } - ScanState.Failed -> { - onScanStateChanged(ScanState.Failed) + ScanScreenState.Failed -> { + onScanStateChanged(ScanScreenState.Failed) } } @@ -542,7 +541,7 @@ private fun ScanMainContent( .constrainAs(bottomItems) { bottom.linkTo(parent.bottom) } ) { ScanBottomItems( - addressValidationResult = addressValidationResult, + validationResult = validationResult, onBack = onBack, onOpenSettings = onOpenSettings, scanState = scanState, @@ -627,7 +626,7 @@ fun ScanCameraView( isTorchOn: Boolean, onScanned: (result: String) -> Unit, permissionState: PermissionState, - setScanState: (ScanState) -> Unit, + setScanState: (ScanScreenState) -> Unit, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current @@ -691,7 +690,7 @@ fun ScanCameraView( ).cameraControl }.onFailure { Twig.error { "Scan QR failed in bind phase with: ${it.message}" } - setScanState(ScanState.Failed) + setScanState(ScanScreenState.Failed) } previewView @@ -755,7 +754,7 @@ private fun ScanPreview() = onOpenSettings = {}, onScanStateChanged = {}, topAppBarSubTitleState = TopAppBarSubTitleState.None, - addressValidationResult = AddressType.Invalid(), + validationResult = ScanValidationState.INVALID, ) } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/viewmodel/ScanViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/viewmodel/ScanViewModel.kt new file mode 100644 index 00000000..16e255df --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/viewmodel/ScanViewModel.kt @@ -0,0 +1,94 @@ +package co.electriccoin.zcash.ui.screen.scan.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import cash.z.ecc.android.sdk.type.AddressType +import co.electriccoin.zcash.ui.common.model.SerializableAddress +import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase +import co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase +import co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase.Zip321ParseUriValidation +import co.electriccoin.zcash.ui.screen.contact.AddContactArgs +import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs +import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs.ADDRESS_BOOK +import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs.DEFAULT +import co.electriccoin.zcash.ui.screen.scan.model.ScanResultState +import co.electriccoin.zcash.ui.screen.scan.model.ScanValidationState +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json + +internal class ScanViewModel( + private val args: ScanNavigationArgs, + private val getSynchronizer: GetSynchronizerUseCase, + private val zip321ParseUriValidationUseCase: Zip321ParseUriValidationUseCase, +) : ViewModel() { + val navigateBack = MutableSharedFlow() + + val navigateCommand = MutableSharedFlow() + + var state = MutableStateFlow(ScanValidationState.NONE) + + private val mutex = Mutex() + + private var hasBeenScannedSuccessfully = false + + fun onScanned(result: String) = + viewModelScope.launch { + mutex.withLock { + if (!hasBeenScannedSuccessfully) { + val addressValidationResult = getSynchronizer().validateAddress(result) + + val zip321ValidationResult = zip321ParseUriValidationUseCase(result) + + state.update { + if (addressValidationResult is AddressType.Valid) { + ScanValidationState.INVALID + } else if (zip321ValidationResult is Zip321ParseUriValidation.Valid) { + ScanValidationState.INVALID + } else { + ScanValidationState.NONE + } + } + + if (zip321ValidationResult is Zip321ParseUriValidation.Valid) { + hasBeenScannedSuccessfully = true + navigateBack.emit(ScanResultState.Zip321Uri(zip321ValidationResult.zip321Uri)) + } else if (addressValidationResult is AddressType.Valid) { + hasBeenScannedSuccessfully = true + + val serializableAddress = SerializableAddress(result, addressValidationResult) + + when (args) { + DEFAULT -> { + navigateBack.emit( + ScanResultState.Address( + Json.encodeToString( + SerializableAddress.serializer(), + serializableAddress + ) + ) + ) + } + + ADDRESS_BOOK -> { + navigateCommand.emit(AddContactArgs(serializableAddress.address)) + } + } + } + } + } + } + + fun onScannedError() = + viewModelScope.launch { + mutex.withLock { + if (!hasBeenScannedSuccessfully) { + state.update { ScanValidationState.INVALID } + } + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt index dcc0aa35..8542c220 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt @@ -17,8 +17,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import cash.z.ecc.android.sdk.Synchronizer +import cash.z.ecc.android.sdk.ext.convertZecToZatoshi +import cash.z.ecc.android.sdk.model.Account +import cash.z.ecc.android.sdk.model.Memo import cash.z.ecc.android.sdk.model.MonetarySeparators import cash.z.ecc.android.sdk.model.UnifiedSpendingKey +import cash.z.ecc.android.sdk.model.WalletAddress import cash.z.ecc.android.sdk.model.ZecSend import cash.z.ecc.android.sdk.model.proposeSend import cash.z.ecc.android.sdk.model.toZecString @@ -50,6 +54,7 @@ import co.electriccoin.zcash.ui.screen.send.view.Send import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.compose.koinInject +import org.zecdev.zip321.ZIP321 import java.util.Locale import kotlin.time.Duration.Companion.seconds @@ -61,6 +66,7 @@ internal fun WrapSend( goBack: () -> Unit, goBalances: () -> Unit, goSendConfirmation: (ZecSend) -> Unit, + goPaymentRequest: (ZecSend, String) -> Unit, goSettings: () -> Unit, ) { val activity = LocalActivity.current @@ -100,6 +106,7 @@ internal fun WrapSend( goBalances = goBalances, goSettings = goSettings, goSendConfirmation = goSendConfirmation, + goPaymentRequest = goPaymentRequest, hasCameraFeature = hasCameraFeature, monetarySeparators = monetarySeparators, topAppBarSubTitleState = walletState, @@ -119,6 +126,7 @@ internal fun WrapSend( goBalances: () -> Unit, goSettings: () -> Unit, goSendConfirmation: (ZecSend) -> Unit, + goPaymentRequest: (ZecSend, String) -> Unit, hasCameraFeature: Boolean, monetarySeparators: MonetarySeparators, onHideBalances: () -> Unit, @@ -156,6 +164,25 @@ internal fun WrapSend( ) } + // Zip321 Uri scan result processing + if (sendArguments?.zip321Uri != null && + synchronizer != null && + spendingKey != null + ) { + LaunchedEffect(goPaymentRequest) { + scope.launch { + processZip321Result( + zip321Uri = sendArguments.zip321Uri, + synchronizer = synchronizer, + account = spendingKey.account, + setSendStage = setSendStage, + setZecSend = setZecSend, + goPaymentRequest = goPaymentRequest + ) + } + } + } + val existingContact: MutableState = remember { mutableStateOf(null) } var isHintVisible by remember { mutableStateOf(false) } @@ -356,3 +383,68 @@ internal fun WrapSend( ) } } + +private suspend fun processZip321Result( + zip321Uri: String, + synchronizer: Synchronizer, + account: Account, + setSendStage: (SendStage) -> Unit, + setZecSend: (ZecSend?) -> Unit, + goPaymentRequest: (ZecSend, String) -> Unit, +) { + val request = + runCatching { + // At this point there should by only a valid Zcash address coming + ZIP321.request(zip321Uri, null) + }.onFailure { + Twig.error(it) { "Failed to validate address" } + }.getOrElse { + false + } + val payment = + when (request) { + // We support only one payment currently + is ZIP321.ParserResult.Request -> { + request.paymentRequest.payments[0] + } + else -> return + } + + val address = + synchronizer + .validateAddress(payment.recipientAddress.value) + .toWalletAddress(payment.recipientAddress.value) + + val amount = payment.nonNegativeAmount.value.convertZecToZatoshi() + + val memo = Memo(payment.memo?.let { String(it.data, Charsets.UTF_8) } ?: "") + + val zecSend = + ZecSend( + destination = address, + amount = amount, + memo = memo, + proposal = null + ) + setZecSend(zecSend) + + runCatching { + synchronizer.proposeFulfillingPaymentUri(account, zip321Uri) + }.onSuccess { proposal -> + Twig.debug { "Transaction proposal from Zip321 Uri: ${proposal.toPrettyString()}" } + val enrichedZecSend = zecSend.copy(proposal = proposal) + setZecSend(enrichedZecSend) + goPaymentRequest(enrichedZecSend, zip321Uri) + }.onFailure { + Twig.error(it) { "Transaction proposal from Zip321 Uri failed" } + setSendStage(SendStage.SendFailure(it.message ?: "")) + } +} + +private suspend fun AddressType.toWalletAddress(value: String) = + when (this) { + AddressType.Unified -> WalletAddress.Unified.new(value) + AddressType.Shielded -> WalletAddress.Sapling.new(value) + AddressType.Transparent -> WalletAddress.Transparent.new(value) + else -> error("Invalid address type") + } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/SendArguments.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/SendArguments.kt index 63bb8263..5dcd54d0 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/SendArguments.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/SendArguments.kt @@ -2,5 +2,6 @@ package co.electriccoin.zcash.ui.screen.send.model data class SendArguments( val recipientAddress: RecipientAddressState? = null, + val zip321Uri: String? = null, val clearForm: Boolean = false, ) diff --git a/ui-lib/src/main/res/ui/payment_request/drawable/ic_chevron_down.xml b/ui-lib/src/main/res/ui/payment_request/drawable/ic_chevron_down.xml new file mode 100644 index 00000000..056bd657 --- /dev/null +++ b/ui-lib/src/main/res/ui/payment_request/drawable/ic_chevron_down.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/payment_request/drawable/ic_chevron_up.xml b/ui-lib/src/main/res/ui/payment_request/drawable/ic_chevron_up.xml new file mode 100644 index 00000000..b42bb060 --- /dev/null +++ b/ui-lib/src/main/res/ui/payment_request/drawable/ic_chevron_up.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/payment_request/drawable/ic_user_plus.xml b/ui-lib/src/main/res/ui/payment_request/drawable/ic_user_plus.xml new file mode 100644 index 00000000..6e47ecec --- /dev/null +++ b/ui-lib/src/main/res/ui/payment_request/drawable/ic_user_plus.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/payment_request/values/strings.xml b/ui-lib/src/main/res/ui/payment_request/values/strings.xml new file mode 100644 index 00000000..578242e3 --- /dev/null +++ b/ui-lib/src/main/res/ui/payment_request/values/strings.xml @@ -0,0 +1,29 @@ + + + Payment Request + Close + + Requested By + Show + Hide + Save + + For: + + Fee + Total + + Send + + Transaction Failed + An error occurred and the attempt to send funds failed. Try it again, please. + OK + Report + + Connection Error + Zashi encountered some connection issues when submitting + the transaction to the network. It will retry during the next few minutes. + OK + + Unable to launch email app. +