[#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 <milan@z.cash>
This commit is contained in:
Honza Rychnovský 2024-10-21 21:11:10 +02:00 committed by GitHub
parent 711feb4251
commit 2129adaa8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1570 additions and 148 deletions

View File

@ -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 - 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 - 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 - 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 ### Added
- Address book local storage support - Address book local storage support

View File

@ -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 - 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 - 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 - 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 ### Added
- New Integrations screen in settings - New Integrations screen in settings

View File

@ -210,7 +210,7 @@ PLAY_PUBLISHER_API_VERSION=v3-rev20231030-2.0.0
PLAY_SERVICES_AUTH_VERSION=21.2.0 PLAY_SERVICES_AUTH_VERSION=21.2.0
ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0 ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0
ZXING_VERSION=3.5.3 ZXING_VERSION=3.5.3
ZIP_321_VERSION = 0.0.3 ZIP_321_VERSION = 0.0.6
ZCASH_BIP39_VERSION=1.0.8 ZCASH_BIP39_VERSION=1.0.8
# WARNING: Ensure a non-snapshot version is used before releasing to production # WARNING: Ensure a non-snapshot version is used before releasing to production

View File

@ -83,7 +83,7 @@ fun ZashiButton(
@Composable @Composable
override fun Loading() { override fun Loading() {
if (enabled && isLoading) { if (isLoading) {
LottieProgress( LottieProgress(
loadingRes = loadingRes =
if (isSystemInDarkTheme()) { if (isSystemInDarkTheme()) {

View File

@ -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.IntegrationTestingActivity
import co.electriccoin.zcash.ui.integration.test.common.getPermissionPositiveButtonUiObject import co.electriccoin.zcash.ui.integration.test.common.getPermissionPositiveButtonUiObject
import co.electriccoin.zcash.ui.screen.scan.ScanTag 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
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Before import org.junit.Before
@ -36,15 +36,15 @@ class ScanViewIntegrationTest : UiTestPrerequisites() {
testSetup.DefaultContent() testSetup.DefaultContent()
} }
assertEquals(testSetup.getScanState(), ScanState.Permission) assertEquals(testSetup.getScanState(), ScanScreenState.Permission)
testSetup.grantPermission() testSetup.grantPermission()
assertEquals(testSetup.getScanState(), ScanState.Scanning) assertEquals(testSetup.getScanState(), ScanScreenState.Scanning)
restorationTester.emulateSavedInstanceStateRestore() restorationTester.emulateSavedInstanceStateRestore()
assertEquals(testSetup.getScanState(), ScanState.Scanning) assertEquals(testSetup.getScanState(), ScanScreenState.Scanning)
} }
@Test @Test

View File

@ -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.getStringResourceWithArgs
import co.electriccoin.zcash.ui.integration.test.common.waitForDeviceIdle import co.electriccoin.zcash.ui.integration.test.common.waitForDeviceIdle
import co.electriccoin.zcash.ui.screen.scan.ScanTag 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.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@ -62,7 +62,7 @@ class ScanViewTest : UiTestPrerequisites() {
@Test @Test
@LargeTest @LargeTest
fun grant_camera_permission() { fun grant_camera_permission() {
assertEquals(ScanState.Permission, testSetup.getScanState()) assertEquals(ScanScreenState.Permission, testSetup.getScanState())
composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also { composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also {
it.assertDoesNotExist() it.assertDoesNotExist()
@ -78,7 +78,7 @@ class ScanViewTest : UiTestPrerequisites() {
it.assertIsDisplayed() it.assertIsDisplayed()
} }
assertEquals(ScanState.Scanning, testSetup.getScanState()) assertEquals(ScanScreenState.Scanning, testSetup.getScanState())
// we need to actively wait for the camera preview initialization // we need to actively wait for the camera preview initialization
waitForDeviceIdle(timeout = 5000.milliseconds) waitForDeviceIdle(timeout = 5000.milliseconds)
@ -91,11 +91,11 @@ class ScanViewTest : UiTestPrerequisites() {
@Test @Test
@LargeTest @LargeTest
fun deny_camera_permission() { fun deny_camera_permission() {
assertEquals(ScanState.Permission, testSetup.getScanState()) assertEquals(ScanScreenState.Permission, testSetup.getScanState())
testSetup.denyPermission() testSetup.denyPermission()
assertEquals(ScanState.Permission, testSetup.getScanState()) assertEquals(ScanScreenState.Permission, testSetup.getScanState())
composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also { composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also {
it.assertDoesNotExist() it.assertDoesNotExist()

View File

@ -3,12 +3,12 @@ package co.electriccoin.zcash.ui.integration.test.screen.scan.view
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule 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.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.design.theme.ZcashTheme 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.getPermissionNegativeButtonUiObject
import co.electriccoin.zcash.ui.integration.test.common.getPermissionPositiveButtonUiObject 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 co.electriccoin.zcash.ui.screen.scan.view.Scan
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@ -19,14 +19,14 @@ class ScanViewTestSetup(
private val composeTestRule: ComposeContentTestRule private val composeTestRule: ComposeContentTestRule
) { ) {
private val onOpenSettingsCount = AtomicInteger(0) private val onOpenSettingsCount = AtomicInteger(0)
private val scanState = AtomicReference(ScanState.Permission) private val scanState = AtomicReference(ScanScreenState.Permission)
fun getOnOpenSettingsCount(): Int { fun getOnOpenSettingsCount(): Int {
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
return onOpenSettingsCount.get() return onOpenSettingsCount.get()
} }
fun getScanState(): ScanState { fun getScanState(): ScanScreenState {
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
return scanState.get() return scanState.get()
} }
@ -61,7 +61,7 @@ class ScanViewTestSetup(
scanState.set(it) scanState.set(it)
}, },
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
addressValidationResult = AddressType.Unified validationResult = ScanValidationState.VALID
) )
} }

View File

@ -47,6 +47,7 @@ android {
"src/main/res/ui/integrations", "src/main/res/ui/integrations",
"src/main/res/ui/new_wallet_recovery", "src/main/res/ui/new_wallet_recovery",
"src/main/res/ui/onboarding", "src/main/res/ui/onboarding",
"src/main/res/ui/payment_request",
"src/main/res/ui/qr_code", "src/main/res/ui/qr_code",
"src/main/res/ui/request", "src/main/res/ui/request",
"src/main/res/ui/receive", "src/main/res/ui/receive",

View File

@ -12,7 +12,7 @@ import androidx.test.rule.GrantPermissionRule
import co.electriccoin.zcash.test.UiTestPrerequisites import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.scan.ScanTag 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 co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
@ -80,7 +80,7 @@ class ScanViewBasicTest : UiTestPrerequisites() {
fun scan_state() { fun scan_state() {
val testSetup = newTestSetup() val testSetup = newTestSetup()
assertEquals(ScanState.Scanning, testSetup.getScanState()) assertEquals(ScanScreenState.Scanning, testSetup.getScanState())
} }
private fun newTestSetup() = private fun newTestSetup() =

View File

@ -3,10 +3,10 @@ package co.electriccoin.zcash.ui.screen.scan.view
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule 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.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.design.theme.ZcashTheme 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.AtomicInteger
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
@ -14,14 +14,14 @@ class ScanViewBasicTestSetup(
private val composeTestRule: ComposeContentTestRule private val composeTestRule: ComposeContentTestRule
) { ) {
private val onBackCount = AtomicInteger(0) private val onBackCount = AtomicInteger(0)
private val scanState = AtomicReference(ScanState.Permission) private val scanState = AtomicReference(ScanScreenState.Permission)
fun getOnBackCount(): Int { fun getOnBackCount(): Int {
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
return onBackCount.get() return onBackCount.get()
} }
fun getScanState(): ScanState { fun getScanState(): ScanScreenState {
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
return scanState.get() return scanState.get()
} }
@ -30,7 +30,7 @@ class ScanViewBasicTestSetup(
@Suppress("TestFunctionName") @Suppress("TestFunctionName")
fun DefaultContent() { fun DefaultContent() {
Scan( Scan(
addressValidationResult = AddressType.Shielded, validationResult = ScanValidationState.VALID,
onBack = { onBack = {
onBackCount.incrementAndGet() onBackCount.incrementAndGet()
}, },

View File

@ -39,6 +39,7 @@ class SendViewIntegrationTest {
goBack = {}, goBack = {},
goBalances = {}, goBalances = {},
goSettings = {}, goSettings = {},
goPaymentRequest = { _, _ -> },
goSendConfirmation = {}, goSendConfirmation = {},
) )
} }

View File

@ -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.ValidateContactNameUseCase
import co.electriccoin.zcash.ui.common.usecase.ValidateEndpointUseCase import co.electriccoin.zcash.ui.common.usecase.ValidateEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.Zip321BuildUriUseCase 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.core.module.dsl.singleOf
import org.koin.dsl.module import org.koin.dsl.module
@ -58,9 +60,11 @@ val useCaseModule =
singleOf(::ObserveContactPickedUseCase) singleOf(::ObserveContactPickedUseCase)
singleOf(::GetAddressesUseCase) singleOf(::GetAddressesUseCase)
singleOf(::CopyToClipboardUseCase) singleOf(::CopyToClipboardUseCase)
singleOf(::ShareImageUseCase)
singleOf(::Zip321BuildUriUseCase)
singleOf(::Zip321ProposalFromUriUseCase)
singleOf(::Zip321ParseUriValidationUseCase)
singleOf(::ObserveWalletStateUseCase) singleOf(::ObserveWalletStateUseCase)
singleOf(::IsCoinbaseAvailableUseCase) singleOf(::IsCoinbaseAvailableUseCase)
singleOf(::GetSpendingKeyUseCase) singleOf(::GetSpendingKeyUseCase)
singleOf(::ShareImageUseCase)
singleOf(::Zip321BuildUriUseCase)
} }

View File

@ -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.contact.viewmodel.UpdateContactViewModel
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel 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.qrcode.viewmodel.QrCodeViewModel
import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel
import co.electriccoin.zcash.ui.screen.request.viewmodel.RequestViewModel import co.electriccoin.zcash.ui.screen.request.viewmodel.RequestViewModel
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
import co.electriccoin.zcash.ui.screen.restoresuccess.viewmodel.RestoreSuccessViewModel 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.sendconfirmation.viewmodel.CreateTransactionsViewModel
import co.electriccoin.zcash.ui.screen.settings.viewmodel.ScreenBrightnessViewModel import co.electriccoin.zcash.ui.screen.settings.viewmodel.ScreenBrightnessViewModel
import co.electriccoin.zcash.ui.screen.settings.viewmodel.SettingsViewModel import co.electriccoin.zcash.ui.screen.settings.viewmodel.SettingsViewModel
@ -67,6 +70,14 @@ val viewModelModule =
viewModelOf(::UpdateContactViewModel) viewModelOf(::UpdateContactViewModel)
viewModelOf(::ReceiveViewModel) viewModelOf(::ReceiveViewModel)
viewModelOf(::QrCodeViewModel) viewModelOf(::QrCodeViewModel)
viewModelOf(::IntegrationsViewModel)
viewModelOf(::RequestViewModel) viewModelOf(::RequestViewModel)
viewModelOf(::PaymentRequestViewModel)
viewModelOf(::IntegrationsViewModel)
viewModel { (args: ScanNavigationArgs) ->
ScanViewModel(
args = args,
getSynchronizer = get(),
zip321ParseUriValidationUseCase = get(),
)
}
} }

View File

@ -22,12 +22,18 @@ import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.spackle.getSerializableCompat import co.electriccoin.zcash.spackle.getSerializableCompat
import co.electriccoin.zcash.ui.NavigationArgs.ADDRESS_TYPE import co.electriccoin.zcash.ui.NavigationArgs.ADDRESS_TYPE
import co.electriccoin.zcash.ui.NavigationArguments.MULTIPLE_SUBMISSION_CLEAR_FORM 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_AMOUNT
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_INITIAL_STAGE 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_MEMO
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_PROPOSAL 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_CONFIRM_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_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.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER 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.HOME
import co.electriccoin.zcash.ui.NavigationTargets.INTEGRATIONS import co.electriccoin.zcash.ui.NavigationTargets.INTEGRATIONS
import co.electriccoin.zcash.ui.NavigationTargets.NOT_ENOUGH_SPACE 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.QR_CODE
import co.electriccoin.zcash.ui.NavigationTargets.REQUEST import co.electriccoin.zcash.ui.NavigationTargets.REQUEST
import co.electriccoin.zcash.ui.NavigationTargets.SEED_RECOVERY 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.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.home.WrapHome import co.electriccoin.zcash.ui.screen.home.WrapHome
import co.electriccoin.zcash.ui.screen.integrations.WrapIntegrations 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.qrcode.WrapQrCode
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
import co.electriccoin.zcash.ui.screen.request.WrapRequest import co.electriccoin.zcash.ui.screen.request.WrapRequest
@ -250,28 +259,7 @@ internal fun MainActivity.Navigation() {
backStackEntry.arguments backStackEntry.arguments
?.getSerializableCompat<ScanNavigationArgs>(ScanNavigationArgs.KEY) ?: ScanNavigationArgs.DEFAULT ?.getSerializableCompat<ScanNavigationArgs>(ScanNavigationArgs.KEY) ?: ScanNavigationArgs.DEFAULT
WrapScanValidator( WrapScanValidator(args = mode)
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) }
)
} }
composable(EXPORT_PRIVATE_DATA) { composable(EXPORT_PRIVATE_DATA) {
WrapExportPrivateData( WrapExportPrivateData(
@ -357,6 +345,13 @@ internal fun MainActivity.Navigation() {
val addressType = backStackEntry.arguments?.getInt(ADDRESS_TYPE) ?: ReceiveAddressType.Unified.ordinal val addressType = backStackEntry.arguments?.getInt(ADDRESS_TYPE) ?: ReceiveAddressType.Unified.ordinal
WrapRequest(addressType) 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) navController.navigateJustOnce(SEND_CONFIRMATION)
}, },
goPaymentRequest = { zecSend, zip321Uri ->
navController.currentBackStackEntry?.savedStateHandle?.let { handle ->
fillInHandleForPaymentRequest(handle, zecSend, zip321Uri)
}
navController.navigateJustOnce(PAYMENT_REQUEST)
},
goSettings = { navController.navigateJustOnce(SETTINGS) }, goSettings = { navController.navigateJustOnce(SETTINGS) },
goMultiTrxSubmissionFailure = { goMultiTrxSubmissionFailure = {
// Ultimately we could approach reworking the MultipleTrxFailure screen into a separate // Ultimately we could approach reworking the MultipleTrxFailure screen into a separate
@ -391,11 +392,13 @@ private fun MainActivity.NavigationHome(
backStack.savedStateHandle.get<String>(SEND_SCAN_RECIPIENT_ADDRESS)?.let { backStack.savedStateHandle.get<String>(SEND_SCAN_RECIPIENT_ADDRESS)?.let {
Json.decodeFromString<SerializableAddress>(it).toRecipient() Json.decodeFromString<SerializableAddress>(it).toRecipient()
}, },
zip321Uri = backStack.savedStateHandle.get<String>(SEND_SCAN_ZIP_321_URI),
clearForm = backStack.savedStateHandle.get<Boolean>(MULTIPLE_SUBMISSION_CLEAR_FORM) ?: false clearForm = backStack.savedStateHandle.get<Boolean>(MULTIPLE_SUBMISSION_CLEAR_FORM) ?: false
).also { ).also {
// Remove Send screen arguments passed from the Scan or MultipleSubmissionFailure screens if // Remove Send screen arguments passed from the Scan or MultipleSubmissionFailure screens if
// some exist after we use them // some exist after we use them
backStack.savedStateHandle.remove<String>(SEND_SCAN_RECIPIENT_ADDRESS) backStack.savedStateHandle.remove<String>(SEND_SCAN_RECIPIENT_ADDRESS)
backStack.savedStateHandle.remove<String>(SEND_SCAN_ZIP_321_URI)
backStack.savedStateHandle.remove<Boolean>(MULTIPLE_SUBMISSION_CLEAR_FORM) backStack.savedStateHandle.remove<Boolean>(MULTIPLE_SUBMISSION_CLEAR_FORM)
}, },
) )
@ -487,6 +490,22 @@ private fun fillInHandleForConfirmation(
handle[SEND_CONFIRM_INITIAL_STAGE] = initialStage.toStringName() 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( private fun NavHostController.navigateJustOnce(
route: String, route: String,
navOptionsBuilder: (NavOptionsBuilder.() -> Unit)? = null navOptionsBuilder: (NavOptionsBuilder.() -> Unit)? = null
@ -508,7 +527,7 @@ private fun NavHostController.navigateJustOnce(
* *
* @param currentRouteToBePopped current screen which should be popped up. * @param currentRouteToBePopped current screen which should be popped up.
*/ */
private fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: String) { fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: String) {
if (currentDestination?.route != currentRouteToBePopped) { if (currentDestination?.route != currentRouteToBePopped) {
return return
} }
@ -517,6 +536,7 @@ private fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: Strin
object NavigationArguments { object NavigationArguments {
const val SEND_SCAN_RECIPIENT_ADDRESS = "send_scan_recipient_address" 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_RECIPIENT_ADDRESS = "send_confirm_recipient_address"
const val SEND_CONFIRM_AMOUNT = "send_confirm_amount" 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 SEND_CONFIRM_INITIAL_STAGE = "send_confirm_initial_stage"
const val MULTIPLE_SUBMISSION_CLEAR_FORM = "multiple_submission_clear_form" 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 { object NavigationTargets {
@ -535,7 +561,9 @@ object NavigationTargets {
const val EXPORT_PRIVATE_DATA = "export_private_data" const val EXPORT_PRIVATE_DATA = "export_private_data"
const val HOME = "home" const val HOME = "home"
const val CHOOSE_SERVER = "choose_server" const val CHOOSE_SERVER = "choose_server"
const val INTEGRATIONS = "integrations"
const val NOT_ENOUGH_SPACE = "not_enough_space" const val NOT_ENOUGH_SPACE = "not_enough_space"
const val PAYMENT_REQUEST = "payment_request"
const val QR_CODE = "qr_code" const val QR_CODE = "qr_code"
const val REQUEST = "request" const val REQUEST = "request"
const val SEED_RECOVERY = "seed_recovery" 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 SETTINGS_EXCHANGE_RATE_OPT_IN = "settings_exchange_rate_opt_in"
const val SUPPORT = "support" const val SUPPORT = "support"
const val WHATS_NEW = "whats_new" const val WHATS_NEW = "whats_new"
const val INTEGRATIONS = "integrations"
} }
object NavigationArgs { object NavigationArgs {

View File

@ -42,15 +42,13 @@ class Zip321BuildUriUseCase {
val paymentRequest = PaymentRequest(payments = listOf(payment)) 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 = val zip321Uri =
ZIP321.uriString( ZIP321.uriString(
paymentRequest, paymentRequest,
ZIP321.FormattingOptions.UseEmptyParamIndex(omitAddressLabel = true) ZIP321.FormattingOptions.UseEmptyParamIndex(omitAddressLabel = true)
) )
Twig.info { "Request Zip321 uri: $zip321Uri" } Twig.debug { "Request Zip321 uri: $zip321Uri" }
return zip321Uri return zip321Uri
} }

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -45,6 +45,7 @@ internal fun WrapHome(
goMultiTrxSubmissionFailure: () -> Unit, goMultiTrxSubmissionFailure: () -> Unit,
goScan: () -> Unit, goScan: () -> Unit,
goSendConfirmation: (ZecSend) -> Unit, goSendConfirmation: (ZecSend) -> Unit,
goPaymentRequest: (ZecSend, String) -> Unit,
sendArguments: SendArguments sendArguments: SendArguments
) { ) {
val homeViewModel = koinActivityViewModel<HomeViewModel>() val homeViewModel = koinActivityViewModel<HomeViewModel>()
@ -87,6 +88,7 @@ internal fun WrapHome(
WrapHome( WrapHome(
goScan = goScan, goScan = goScan,
goSendConfirmation = goSendConfirmation, goSendConfirmation = goSendConfirmation,
goPaymentRequest = goPaymentRequest,
goSettings = goSettings, goSettings = goSettings,
goMultiTrxSubmissionFailure = goMultiTrxSubmissionFailure, goMultiTrxSubmissionFailure = goMultiTrxSubmissionFailure,
isKeepScreenOnWhileSyncing = isKeepScreenOnWhileSyncing, isKeepScreenOnWhileSyncing = isKeepScreenOnWhileSyncing,
@ -105,6 +107,7 @@ internal fun WrapHome(
goMultiTrxSubmissionFailure: () -> Unit, goMultiTrxSubmissionFailure: () -> Unit,
goScan: () -> Unit, goScan: () -> Unit,
goSendConfirmation: (ZecSend) -> Unit, goSendConfirmation: (ZecSend) -> Unit,
goPaymentRequest: (ZecSend, String) -> Unit,
isKeepScreenOnWhileSyncing: Boolean?, isKeepScreenOnWhileSyncing: Boolean?,
isShowingRestoreSuccess: Boolean, isShowingRestoreSuccess: Boolean,
sendArguments: SendArguments, sendArguments: SendArguments,
@ -182,6 +185,7 @@ internal fun WrapHome(
} }
}, },
goSendConfirmation = goSendConfirmation, goSendConfirmation = goSendConfirmation,
goPaymentRequest = goPaymentRequest,
goSettings = goSettings, goSettings = goSettings,
sendArguments = sendArguments sendArguments = sendArguments
) )

View File

@ -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<WalletViewModel>()
val walletState by walletViewModel.walletStateInformation.collectAsStateWithLifecycle()
val paymentRequestViewModel = koinViewModel<PaymentRequestViewModel> { parametersOf(arguments) }
val paymentRequestState by paymentRequestViewModel.state.collectAsStateWithLifecycle()
val authenticateForProposal = rememberSaveable { mutableStateOf<Proposal?>(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
)
}
}

View File

@ -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",
)
}

View File

@ -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<String>(NavigationArguments.PAYMENT_REQUEST_ADDRESS)?.let {
Json.decodeFromString<SerializableAddress>(it)
},
amount = savedStateHandle.get<Long>(NavigationArguments.PAYMENT_REQUEST_AMOUNT),
memo = savedStateHandle.get<String>(NavigationArguments.PAYMENT_REQUEST_MEMO),
proposal =
savedStateHandle.get<ByteArray>(NavigationArguments.PAYMENT_REQUEST_PROPOSAL)?.let {
FirstClassByteArray(it)
},
zip321Uri = savedStateHandle.get<String>(NavigationArguments.PAYMENT_REQUEST_URI),
).also {
// Remove the screen arguments passed from the other screen if some exist
savedStateHandle.remove<String>(NavigationArguments.PAYMENT_REQUEST_ADDRESS)
savedStateHandle.remove<Long>(NavigationArguments.PAYMENT_REQUEST_AMOUNT)
savedStateHandle.remove<String>(NavigationArguments.PAYMENT_REQUEST_MEMO)
savedStateHandle.remove<ByteArray>(NavigationArguments.PAYMENT_REQUEST_PROPOSAL)
savedStateHandle.remove<String>(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"),
)
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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
)
}

View File

@ -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>(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<Unit>()
internal val closeNavigationCommand = MutableSharedFlow<Unit>()
internal val addContactNavigationCommand = MutableSharedFlow<String>()
internal val homeNavigationCommand = MutableSharedFlow<Unit>()
internal val authenticationNavigationCommand = MutableSharedFlow<Proposal>()
internal val sendReportFailedNavigationCommand = MutableSharedFlow<Unit>()
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)
}
}
}

View File

@ -6,10 +6,12 @@ import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
@ -435,6 +437,14 @@ private fun ColumnScope.QrCode(
), ),
shape = RoundedCornerShape(ZashiDimensions.Radius.radius4xl) shape = RoundedCornerShape(ZashiDimensions.Radius.radius4xl)
) )
.background(
if (isSystemInDarkTheme()) {
ZashiColors.Surfaces.bgAlt
} else {
ZashiColors.Surfaces.bgPrimary
},
RoundedCornerShape(ZashiDimensions.Radius.radius4xl)
)
.padding(all = 12.dp) .padding(all = 12.dp)
) )
} }

View File

@ -2,9 +2,11 @@ package co.electriccoin.zcash.ui.screen.request.view
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
@ -133,6 +135,14 @@ private fun ColumnScope.QrCode(
), ),
shape = RoundedCornerShape(ZashiDimensions.Radius.radius4xl) shape = RoundedCornerShape(ZashiDimensions.Radius.radius4xl)
) )
.background(
if (isSystemInDarkTheme()) {
ZashiColors.Surfaces.bgAlt
} else {
ZashiColors.Surfaces.bgPrimary
},
RoundedCornerShape(ZashiDimensions.Radius.radius4xl)
)
.padding(all = 12.dp) .padding(all = 12.dp)
) )
} }

View File

@ -3,68 +3,62 @@ package co.electriccoin.zcash.ui.screen.scan
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.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.R
import co.electriccoin.zcash.ui.common.model.SerializableAddress import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator 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.view.Scan
import co.electriccoin.zcash.ui.screen.scan.viewmodel.ScanViewModel
import co.electriccoin.zcash.ui.util.SettingsUtil import co.electriccoin.zcash.ui.util.SettingsUtil
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import org.koin.androidx.compose.koinViewModel
import kotlinx.coroutines.sync.withLock import org.koin.core.parameter.parametersOf
@Composable @Composable
internal fun WrapScanValidator( internal fun WrapScanValidator(args: ScanNavigationArgs) {
onScanValid: (address: SerializableAddress) -> Unit, val navController = LocalNavController.current
goBack: () -> Unit val context = LocalContext.current
) { val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val walletViewModel = koinActivityViewModel<WalletViewModel>() val walletViewModel = koinActivityViewModel<WalletViewModel>()
val viewModel = koinViewModel<ScanViewModel> { parametersOf(args) }
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
val walletState = walletViewModel.walletStateInformation.collectAsStateWithLifecycle().value val walletState = walletViewModel.walletStateInformation.collectAsStateWithLifecycle().value
val state by viewModel.state.collectAsStateWithLifecycle()
BackHandler { BackHandler {
goBack() navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE)
} }
WrapScan( LaunchedEffect(Unit) {
onScanValid = onScanValid, viewModel.navigateBack.collect { scanResult ->
goBack = goBack, navController.previousBackStackEntry?.savedStateHandle?.apply {
synchronizer = synchronizer, when (scanResult) {
topAppBarSubTitleState = walletState, 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 LaunchedEffect(Unit) {
fun WrapScan( viewModel.navigateCommand.collect {
goBack: () -> Unit, navController.popBackStack()
onScanValid: (address: SerializableAddress) -> Unit, navController.navigate(it)
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<AddressType?>(null) }
if (synchronizer == null) { if (synchronizer == null) {
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer // TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
@ -74,24 +68,13 @@ fun WrapScan(
} else { } else {
Scan( Scan(
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
addressValidationResult = addressValidationResult, validationResult = state,
onBack = goBack, onBack = { navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE) },
onScanned = { result -> onScanned = {
scope.launch { viewModel.onScanned(it)
mutex.withLock {
if (!hasBeenScannedSuccessfully) {
addressValidationResult = synchronizer.validateAddress(result)
val isAddressValid = addressValidationResult?.let { !it.isNotValid } ?: false
if (isAddressValid) {
hasBeenScannedSuccessfully = true
onScanValid(SerializableAddress(result, addressValidationResult!!))
}
}
}
}
}, },
onScanError = { onScanError = {
addressValidationResult = AddressType.Invalid() viewModel.onScannedError()
}, },
onOpenSettings = { onOpenSettings = {
runCatching { runCatching {
@ -107,7 +90,7 @@ fun WrapScan(
} }
}, },
onScanStateChanged = {}, onScanStateChanged = {},
topAppBarSubTitleState = topAppBarSubTitleState, topAppBarSubTitleState = walletState,
) )
} }
} }

View File

@ -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()
}

View File

@ -1,6 +1,6 @@
package co.electriccoin.zcash.ui.screen.scan.model package co.electriccoin.zcash.ui.screen.scan.model
enum class ScanState { enum class ScanScreenState {
Failed, Failed,
Permission, Permission,
Scanning Scanning

View File

@ -0,0 +1,7 @@
package co.electriccoin.zcash.ui.screen.scan.model
enum class ScanValidationState {
NONE,
INVALID,
VALID
}

View File

@ -74,7 +74,6 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension import androidx.constraintlayout.compose.Dimension
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState 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.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.screen.scan.ScanTag 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.ImageUriToQrCodeConverter
import co.electriccoin.zcash.ui.screen.scan.util.QrCodeAnalyzer import co.electriccoin.zcash.ui.screen.scan.util.QrCodeAnalyzer
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
@ -111,9 +111,9 @@ fun Scan(
onScanned: (String) -> Unit, onScanned: (String) -> Unit,
onScanError: () -> Unit, onScanError: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onScanStateChanged: (ScanState) -> Unit, onScanStateChanged: (ScanScreenState) -> Unit,
topAppBarSubTitleState: TopAppBarSubTitleState, topAppBarSubTitleState: TopAppBarSubTitleState,
addressValidationResult: AddressType? validationResult: ScanValidationState
) = ZcashTheme(forceDarkMode = true) { // forces dark theme for this screen ) = ZcashTheme(forceDarkMode = true) { // forces dark theme for this screen
val permissionState = val permissionState =
if (LocalInspectionMode.current) { if (LocalInspectionMode.current) {
@ -134,15 +134,15 @@ fun Scan(
val (scanState, setScanState) = val (scanState, setScanState) =
if (LocalInspectionMode.current) { if (LocalInspectionMode.current) {
remember { remember {
mutableStateOf(ScanState.Scanning) mutableStateOf(ScanScreenState.Scanning)
} }
} else { } else {
rememberSaveable { rememberSaveable {
mutableStateOf( mutableStateOf(
if (permissionState.status.isGranted) { if (permissionState.status.isGranted) {
ScanState.Scanning ScanScreenState.Scanning
} else { } else {
ScanState.Permission ScanScreenState.Permission
} }
) )
} }
@ -153,7 +153,7 @@ fun Scan(
) { _ -> ) { _ ->
Box { Box {
ScanMainContent( ScanMainContent(
addressValidationResult = addressValidationResult, validationResult = validationResult,
onScanned = onScanned, onScanned = onScanned,
onScanError = onScanError, onScanError = onScanError,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
@ -166,7 +166,7 @@ fun Scan(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(
if (scanState != ScanState.Scanning) { if (scanState != ScanScreenState.Scanning) {
ZcashTheme.colors.cameraDisabledBackgroundColor ZcashTheme.colors.cameraDisabledBackgroundColor
} else { } else {
Color.Black Color.Black
@ -177,7 +177,7 @@ fun Scan(
ScanTopAppBar( ScanTopAppBar(
onBack = onBack, onBack = onBack,
showBack = scanState != ScanState.Scanning, showBack = scanState != ScanScreenState.Scanning,
subTitleState = topAppBarSubTitleState, subTitleState = topAppBarSubTitleState,
) )
} }
@ -186,8 +186,8 @@ fun Scan(
@Composable @Composable
fun ScanBottomItems( fun ScanBottomItems(
addressValidationResult: AddressType?, validationResult: ScanValidationState,
scanState: ScanState, scanState: ScanScreenState,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -195,22 +195,21 @@ fun ScanBottomItems(
Column(modifier) { Column(modifier) {
var failureText: String? = null var failureText: String? = null
// Check validation result, if any if (validationResult == ScanValidationState.INVALID) {
if (addressValidationResult is AddressType.Invalid) {
failureText = stringResource(id = R.string.scan_address_validation_failed) failureText = stringResource(id = R.string.scan_address_validation_failed)
} }
// Check permission request result, if any // Check permission request result, if any
failureText = failureText =
when (scanState) { when (scanState) {
ScanState.Permission -> ScanScreenState.Permission ->
stringResource( stringResource(
id = R.string.scan_state_permission, id = R.string.scan_state_permission,
stringResource(id = R.string.app_name) stringResource(id = R.string.app_name)
) )
ScanState.Failed -> stringResource(id = R.string.scan_state_failed) ScanScreenState.Failed -> stringResource(id = R.string.scan_state_failed)
ScanState.Scanning -> failureText ScanScreenState.Scanning -> failureText
} }
if (failureText != null) { if (failureText != null) {
@ -236,7 +235,7 @@ fun ScanBottomItems(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
when (scanState) { when (scanState) {
ScanState.Scanning, ScanState.Failed -> { ScanScreenState.Scanning, ScanScreenState.Failed -> {
ZashiButton( ZashiButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = onBack, onClick = onBack,
@ -244,7 +243,7 @@ fun ScanBottomItems(
) )
} }
ScanState.Permission -> { ScanScreenState.Permission -> {
ZashiButton( ZashiButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = onOpenSettings, onClick = onOpenSettings,
@ -306,20 +305,20 @@ data class FramePosition(
) )
@Composable @Composable
private fun ScanMainContent( private fun ScanMainContent(
addressValidationResult: AddressType?, validationResult: ScanValidationState,
onScanned: (String) -> Unit, onScanned: (String) -> Unit,
onScanError: () -> Unit, onScanError: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onScanStateChanged: (ScanState) -> Unit, onScanStateChanged: (ScanScreenState) -> Unit,
permissionState: PermissionState, permissionState: PermissionState,
scanState: ScanState, scanState: ScanScreenState,
setScanState: (ScanState) -> Unit, setScanState: (ScanScreenState) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when { when {
(!permissionState.status.isGranted) -> { (!permissionState.status.isGranted) -> {
setScanState(ScanState.Permission) setScanState(ScanScreenState.Permission)
if (permissionState.status.shouldShowRationale) { if (permissionState.status.shouldShowRationale) {
// Keep dark screen with a link to the app settings - user denied the permission previously // Keep dark screen with a link to the app settings - user denied the permission previously
} else { } else {
@ -329,13 +328,13 @@ private fun ScanMainContent(
} }
} }
(scanState == ScanState.Failed) -> { (scanState == ScanScreenState.Failed) -> {
// Keep current state // Keep current state
} }
(permissionState.status.isGranted) -> { (permissionState.status.isGranted) -> {
if (scanState != ScanState.Scanning) { if (scanState != ScanScreenState.Scanning) {
setScanState(ScanState.Scanning) setScanState(ScanScreenState.Scanning)
} }
} }
} }
@ -411,13 +410,13 @@ private fun ScanMainContent(
val (frame, frameWindow, bottomItems, topAnchor) = createRefs() val (frame, frameWindow, bottomItems, topAnchor) = createRefs()
when (scanState) { when (scanState) {
ScanState.Permission -> { ScanScreenState.Permission -> {
// Keep initial ui state // Keep initial ui state
onScanStateChanged(ScanState.Permission) onScanStateChanged(ScanScreenState.Permission)
} }
ScanState.Scanning -> { ScanScreenState.Scanning -> {
onScanStateChanged(ScanState.Scanning) onScanStateChanged(ScanScreenState.Scanning)
if (!LocalInspectionMode.current) { if (!LocalInspectionMode.current) {
ScanCameraView( ScanCameraView(
@ -487,8 +486,8 @@ private fun ScanMainContent(
} }
} }
ScanState.Failed -> { ScanScreenState.Failed -> {
onScanStateChanged(ScanState.Failed) onScanStateChanged(ScanScreenState.Failed)
} }
} }
@ -542,7 +541,7 @@ private fun ScanMainContent(
.constrainAs(bottomItems) { bottom.linkTo(parent.bottom) } .constrainAs(bottomItems) { bottom.linkTo(parent.bottom) }
) { ) {
ScanBottomItems( ScanBottomItems(
addressValidationResult = addressValidationResult, validationResult = validationResult,
onBack = onBack, onBack = onBack,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
scanState = scanState, scanState = scanState,
@ -627,7 +626,7 @@ fun ScanCameraView(
isTorchOn: Boolean, isTorchOn: Boolean,
onScanned: (result: String) -> Unit, onScanned: (result: String) -> Unit,
permissionState: PermissionState, permissionState: PermissionState,
setScanState: (ScanState) -> Unit, setScanState: (ScanScreenState) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@ -691,7 +690,7 @@ fun ScanCameraView(
).cameraControl ).cameraControl
}.onFailure { }.onFailure {
Twig.error { "Scan QR failed in bind phase with: ${it.message}" } Twig.error { "Scan QR failed in bind phase with: ${it.message}" }
setScanState(ScanState.Failed) setScanState(ScanScreenState.Failed)
} }
previewView previewView
@ -755,7 +754,7 @@ private fun ScanPreview() =
onOpenSettings = {}, onOpenSettings = {},
onScanStateChanged = {}, onScanStateChanged = {},
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
addressValidationResult = AddressType.Invalid(), validationResult = ScanValidationState.INVALID,
) )
} }
} }

View File

@ -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<ScanResultState>()
val navigateCommand = MutableSharedFlow<String>()
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 }
}
}
}
}

View File

@ -17,8 +17,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.Synchronizer 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.MonetarySeparators
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey 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.ZecSend
import cash.z.ecc.android.sdk.model.proposeSend import cash.z.ecc.android.sdk.model.proposeSend
import cash.z.ecc.android.sdk.model.toZecString 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.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.zecdev.zip321.ZIP321
import java.util.Locale import java.util.Locale
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -61,6 +66,7 @@ internal fun WrapSend(
goBack: () -> Unit, goBack: () -> Unit,
goBalances: () -> Unit, goBalances: () -> Unit,
goSendConfirmation: (ZecSend) -> Unit, goSendConfirmation: (ZecSend) -> Unit,
goPaymentRequest: (ZecSend, String) -> Unit,
goSettings: () -> Unit, goSettings: () -> Unit,
) { ) {
val activity = LocalActivity.current val activity = LocalActivity.current
@ -100,6 +106,7 @@ internal fun WrapSend(
goBalances = goBalances, goBalances = goBalances,
goSettings = goSettings, goSettings = goSettings,
goSendConfirmation = goSendConfirmation, goSendConfirmation = goSendConfirmation,
goPaymentRequest = goPaymentRequest,
hasCameraFeature = hasCameraFeature, hasCameraFeature = hasCameraFeature,
monetarySeparators = monetarySeparators, monetarySeparators = monetarySeparators,
topAppBarSubTitleState = walletState, topAppBarSubTitleState = walletState,
@ -119,6 +126,7 @@ internal fun WrapSend(
goBalances: () -> Unit, goBalances: () -> Unit,
goSettings: () -> Unit, goSettings: () -> Unit,
goSendConfirmation: (ZecSend) -> Unit, goSendConfirmation: (ZecSend) -> Unit,
goPaymentRequest: (ZecSend, String) -> Unit,
hasCameraFeature: Boolean, hasCameraFeature: Boolean,
monetarySeparators: MonetarySeparators, monetarySeparators: MonetarySeparators,
onHideBalances: () -> Unit, 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<AddressBookContact?> = remember { mutableStateOf(null) } val existingContact: MutableState<AddressBookContact?> = remember { mutableStateOf(null) }
var isHintVisible by remember { mutableStateOf(false) } 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")
}

View File

@ -2,5 +2,6 @@ package co.electriccoin.zcash.ui.screen.send.model
data class SendArguments( data class SendArguments(
val recipientAddress: RecipientAddressState? = null, val recipientAddress: RecipientAddressState? = null,
val zip321Uri: String? = null,
val clearForm: Boolean = false, val clearForm: Boolean = false,
) )

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M5,7.5L10,12.5L15,7.5"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#4D4941"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M15,12.5L10,7.5L5,12.5"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#4D4941"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M10,12.917H6.25C5.087,12.917 4.505,12.917 4.032,13.06C2.967,13.383 2.133,14.217 1.81,15.282C1.667,15.755 1.667,16.337 1.667,17.5M15.833,17.5V12.5M13.333,15H18.333M12.083,6.25C12.083,8.321 10.404,10 8.333,10C6.262,10 4.583,8.321 4.583,6.25C4.583,4.179 6.262,2.5 8.333,2.5C10.404,2.5 12.083,4.179 12.083,6.25Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#4D4941"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="payment_request_title">Payment Request</string>
<string name="payment_request_close_content_description">Close</string>
<string name="payment_request_requested_by">Requested By</string>
<string name="payment_request_btn_show_address">Show</string>
<string name="payment_request_btn_hide_address">Hide</string>
<string name="payment_request_btn_save_contact">Save</string>
<string name="payment_request_memo">For:</string>
<string name="payment_request_fee">Fee</string>
<string name="payment_request_total">Total</string>
<string name="payment_request_send_btn">Send</string>
<string name="payment_request_dialog_error_title">Transaction Failed</string>
<string name="payment_request_dialog_error_text">An error occurred and the attempt to send funds failed. Try it again, please.</string>
<string name="payment_request_dialog_error_ok_btn">OK</string>
<string name="payment_request_dialog_error_report_btn">Report</string>
<string name="payment_request_dialog_error_grpc_title">Connection Error</string>
<string name="payment_request_dialog_error_grpc_text">Zashi encountered some connection issues when submitting
the transaction to the network. It will retry during the next few minutes.</string>
<string name="payment_request_dialog_error_grpc_btn">OK</string>
<string name="payment_request_send_failed_report_unable_open_email">Unable to launch email app.</string>
</resources>