[#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
- History item redesigned & added an option to create a contact from unknown address
- Address Book, Create/Update/Delete Contact, Create Contact by QR screens added
- The Scan QR code screen now supports scanning of ZIP 321 Uris
### Added
- Address book local storage support

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
- History item redesigned & added an option to create a contact from unknown address
- Address Book, Create/Update/Delete Contact, Create Contact by QR screens added
- The Scan QR code screen now supports scanning of ZIP 321 Uris
### Added
- New Integrations screen in settings

View File

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

View File

@ -83,7 +83,7 @@ fun ZashiButton(
@Composable
override fun Loading() {
if (enabled && isLoading) {
if (isLoading) {
LottieProgress(
loadingRes =
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.getPermissionPositiveButtonUiObject
import co.electriccoin.zcash.ui.screen.scan.ScanTag
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Before
@ -36,15 +36,15 @@ class ScanViewIntegrationTest : UiTestPrerequisites() {
testSetup.DefaultContent()
}
assertEquals(testSetup.getScanState(), ScanState.Permission)
assertEquals(testSetup.getScanState(), ScanScreenState.Permission)
testSetup.grantPermission()
assertEquals(testSetup.getScanState(), ScanState.Scanning)
assertEquals(testSetup.getScanState(), ScanScreenState.Scanning)
restorationTester.emulateSavedInstanceStateRestore()
assertEquals(testSetup.getScanState(), ScanState.Scanning)
assertEquals(testSetup.getScanState(), ScanScreenState.Scanning)
}
@Test

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -22,12 +22,18 @@ import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.spackle.getSerializableCompat
import co.electriccoin.zcash.ui.NavigationArgs.ADDRESS_TYPE
import co.electriccoin.zcash.ui.NavigationArguments.MULTIPLE_SUBMISSION_CLEAR_FORM
import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_ADDRESS
import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_AMOUNT
import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_MEMO
import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_PROPOSAL
import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_URI
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_AMOUNT
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_INITIAL_STAGE
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_MEMO
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_PROPOSAL
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_ZIP_321_URI
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER
@ -37,6 +43,7 @@ import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA
import co.electriccoin.zcash.ui.NavigationTargets.HOME
import co.electriccoin.zcash.ui.NavigationTargets.INTEGRATIONS
import co.electriccoin.zcash.ui.NavigationTargets.NOT_ENOUGH_SPACE
import co.electriccoin.zcash.ui.NavigationTargets.PAYMENT_REQUEST
import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE
import co.electriccoin.zcash.ui.NavigationTargets.REQUEST
import co.electriccoin.zcash.ui.NavigationTargets.SEED_RECOVERY
@ -71,6 +78,8 @@ import co.electriccoin.zcash.ui.screen.exchangerate.settings.AndroidSettingsExch
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.home.WrapHome
import co.electriccoin.zcash.ui.screen.integrations.WrapIntegrations
import co.electriccoin.zcash.ui.screen.paymentrequest.WrapPaymentRequest
import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestArguments
import co.electriccoin.zcash.ui.screen.qrcode.WrapQrCode
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
import co.electriccoin.zcash.ui.screen.request.WrapRequest
@ -250,28 +259,7 @@ internal fun MainActivity.Navigation() {
backStackEntry.arguments
?.getSerializableCompat<ScanNavigationArgs>(ScanNavigationArgs.KEY) ?: ScanNavigationArgs.DEFAULT
WrapScanValidator(
onScanValid = { scanResult ->
when (mode) {
ScanNavigationArgs.DEFAULT -> {
navController.previousBackStackEntry?.savedStateHandle?.apply {
set(
SEND_SCAN_RECIPIENT_ADDRESS,
Json.encodeToString(SerializableAddress.serializer(), scanResult)
)
}
navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE)
}
ScanNavigationArgs.ADDRESS_BOOK -> {
val address = scanResult.address
navController.popBackStack()
navController.navigate(AddContactArgs(address))
}
}
},
goBack = { navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE) }
)
WrapScanValidator(args = mode)
}
composable(EXPORT_PRIVATE_DATA) {
WrapExportPrivateData(
@ -357,6 +345,13 @@ internal fun MainActivity.Navigation() {
val addressType = backStackEntry.arguments?.getInt(ADDRESS_TYPE) ?: ReceiveAddressType.Unified.ordinal
WrapRequest(addressType)
}
composable(PAYMENT_REQUEST) {
navController.previousBackStackEntry?.let { backStackEntry ->
WrapPaymentRequest(
arguments = PaymentRequestArguments.fromSavedStateHandle(backStackEntry.savedStateHandle)
)
}
}
}
}
@ -376,6 +371,12 @@ private fun MainActivity.NavigationHome(
}
navController.navigateJustOnce(SEND_CONFIRMATION)
},
goPaymentRequest = { zecSend, zip321Uri ->
navController.currentBackStackEntry?.savedStateHandle?.let { handle ->
fillInHandleForPaymentRequest(handle, zecSend, zip321Uri)
}
navController.navigateJustOnce(PAYMENT_REQUEST)
},
goSettings = { navController.navigateJustOnce(SETTINGS) },
goMultiTrxSubmissionFailure = {
// Ultimately we could approach reworking the MultipleTrxFailure screen into a separate
@ -391,11 +392,13 @@ private fun MainActivity.NavigationHome(
backStack.savedStateHandle.get<String>(SEND_SCAN_RECIPIENT_ADDRESS)?.let {
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
).also {
// Remove Send screen arguments passed from the Scan or MultipleSubmissionFailure screens if
// some exist after we use them
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)
},
)
@ -487,6 +490,22 @@ private fun fillInHandleForConfirmation(
handle[SEND_CONFIRM_INITIAL_STAGE] = initialStage.toStringName()
}
private fun fillInHandleForPaymentRequest(
handle: SavedStateHandle,
zecSend: ZecSend,
zip321: String
) {
handle[PAYMENT_REQUEST_ADDRESS] =
Json.encodeToString(
serializer = SerializableAddress.serializer(),
value = zecSend.destination.toSerializableAddress()
)
handle[PAYMENT_REQUEST_AMOUNT] = zecSend.amount.value
handle[PAYMENT_REQUEST_MEMO] = zecSend.memo.value
handle[PAYMENT_REQUEST_PROPOSAL] = zecSend.proposal?.toByteArray() ?: byteArrayOf()
handle[PAYMENT_REQUEST_URI] = zip321
}
private fun NavHostController.navigateJustOnce(
route: String,
navOptionsBuilder: (NavOptionsBuilder.() -> Unit)? = null
@ -508,7 +527,7 @@ private fun NavHostController.navigateJustOnce(
*
* @param currentRouteToBePopped current screen which should be popped up.
*/
private fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: String) {
fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: String) {
if (currentDestination?.route != currentRouteToBePopped) {
return
}
@ -517,6 +536,7 @@ private fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: Strin
object NavigationArguments {
const val SEND_SCAN_RECIPIENT_ADDRESS = "send_scan_recipient_address"
const val SEND_SCAN_ZIP_321_URI = "send_scan_zip_321_uri"
const val SEND_CONFIRM_RECIPIENT_ADDRESS = "send_confirm_recipient_address"
const val SEND_CONFIRM_AMOUNT = "send_confirm_amount"
@ -525,6 +545,12 @@ object NavigationArguments {
const val SEND_CONFIRM_INITIAL_STAGE = "send_confirm_initial_stage"
const val MULTIPLE_SUBMISSION_CLEAR_FORM = "multiple_submission_clear_form"
const val PAYMENT_REQUEST_ADDRESS = "payment_request_address"
const val PAYMENT_REQUEST_AMOUNT = "payment_request_amount"
const val PAYMENT_REQUEST_MEMO = "payment_request_memo"
const val PAYMENT_REQUEST_PROPOSAL = "payment_request_proposal"
const val PAYMENT_REQUEST_URI = "payment_request_uri"
}
object NavigationTargets {
@ -535,7 +561,9 @@ object NavigationTargets {
const val EXPORT_PRIVATE_DATA = "export_private_data"
const val HOME = "home"
const val CHOOSE_SERVER = "choose_server"
const val INTEGRATIONS = "integrations"
const val NOT_ENOUGH_SPACE = "not_enough_space"
const val PAYMENT_REQUEST = "payment_request"
const val QR_CODE = "qr_code"
const val REQUEST = "request"
const val SEED_RECOVERY = "seed_recovery"
@ -544,7 +572,6 @@ object NavigationTargets {
const val SETTINGS_EXCHANGE_RATE_OPT_IN = "settings_exchange_rate_opt_in"
const val SUPPORT = "support"
const val WHATS_NEW = "whats_new"
const val INTEGRATIONS = "integrations"
}
object NavigationArgs {

View File

@ -42,15 +42,13 @@ class Zip321BuildUriUseCase {
val paymentRequest = PaymentRequest(payments = listOf(payment))
// TODO [#1636]: Use fixed ZIP321 library version
// TODO [#1636]: https://github.com/Electric-Coin-Company/zashi-android/issues/1636
val zip321Uri =
ZIP321.uriString(
paymentRequest,
ZIP321.FormattingOptions.UseEmptyParamIndex(omitAddressLabel = true)
)
Twig.info { "Request Zip321 uri: $zip321Uri" }
Twig.debug { "Request Zip321 uri: $zip321Uri" }
return zip321Uri
}

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

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

View File

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

View File

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

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

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.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.ext.convertZecToZatoshi
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.Memo
import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.proposeSend
import cash.z.ecc.android.sdk.model.toZecString
@ -50,6 +54,7 @@ import co.electriccoin.zcash.ui.screen.send.view.Send
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.zecdev.zip321.ZIP321
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@ -61,6 +66,7 @@ internal fun WrapSend(
goBack: () -> Unit,
goBalances: () -> Unit,
goSendConfirmation: (ZecSend) -> Unit,
goPaymentRequest: (ZecSend, String) -> Unit,
goSettings: () -> Unit,
) {
val activity = LocalActivity.current
@ -100,6 +106,7 @@ internal fun WrapSend(
goBalances = goBalances,
goSettings = goSettings,
goSendConfirmation = goSendConfirmation,
goPaymentRequest = goPaymentRequest,
hasCameraFeature = hasCameraFeature,
monetarySeparators = monetarySeparators,
topAppBarSubTitleState = walletState,
@ -119,6 +126,7 @@ internal fun WrapSend(
goBalances: () -> Unit,
goSettings: () -> Unit,
goSendConfirmation: (ZecSend) -> Unit,
goPaymentRequest: (ZecSend, String) -> Unit,
hasCameraFeature: Boolean,
monetarySeparators: MonetarySeparators,
onHideBalances: () -> Unit,
@ -156,6 +164,25 @@ internal fun WrapSend(
)
}
// Zip321 Uri scan result processing
if (sendArguments?.zip321Uri != null &&
synchronizer != null &&
spendingKey != null
) {
LaunchedEffect(goPaymentRequest) {
scope.launch {
processZip321Result(
zip321Uri = sendArguments.zip321Uri,
synchronizer = synchronizer,
account = spendingKey.account,
setSendStage = setSendStage,
setZecSend = setZecSend,
goPaymentRequest = goPaymentRequest
)
}
}
}
val existingContact: MutableState<AddressBookContact?> = remember { mutableStateOf(null) }
var isHintVisible by remember { mutableStateOf(false) }
@ -356,3 +383,68 @@ internal fun WrapSend(
)
}
}
private suspend fun processZip321Result(
zip321Uri: String,
synchronizer: Synchronizer,
account: Account,
setSendStage: (SendStage) -> Unit,
setZecSend: (ZecSend?) -> Unit,
goPaymentRequest: (ZecSend, String) -> Unit,
) {
val request =
runCatching {
// At this point there should by only a valid Zcash address coming
ZIP321.request(zip321Uri, null)
}.onFailure {
Twig.error(it) { "Failed to validate address" }
}.getOrElse {
false
}
val payment =
when (request) {
// We support only one payment currently
is ZIP321.ParserResult.Request -> {
request.paymentRequest.payments[0]
}
else -> return
}
val address =
synchronizer
.validateAddress(payment.recipientAddress.value)
.toWalletAddress(payment.recipientAddress.value)
val amount = payment.nonNegativeAmount.value.convertZecToZatoshi()
val memo = Memo(payment.memo?.let { String(it.data, Charsets.UTF_8) } ?: "")
val zecSend =
ZecSend(
destination = address,
amount = amount,
memo = memo,
proposal = null
)
setZecSend(zecSend)
runCatching {
synchronizer.proposeFulfillingPaymentUri(account, zip321Uri)
}.onSuccess { proposal ->
Twig.debug { "Transaction proposal from Zip321 Uri: ${proposal.toPrettyString()}" }
val enrichedZecSend = zecSend.copy(proposal = proposal)
setZecSend(enrichedZecSend)
goPaymentRequest(enrichedZecSend, zip321Uri)
}.onFailure {
Twig.error(it) { "Transaction proposal from Zip321 Uri failed" }
setSendStage(SendStage.SendFailure(it.message ?: ""))
}
}
private suspend fun AddressType.toWalletAddress(value: String) =
when (this) {
AddressType.Unified -> WalletAddress.Unified.new(value)
AddressType.Shielded -> WalletAddress.Sapling.new(value)
AddressType.Transparent -> WalletAddress.Transparent.new(value)
else -> error("Invalid address type")
}

View File

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

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>