diff --git a/CHANGELOG.md b/CHANGELOG.md index 83c17c20..5086f72e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ directly impact users rather than highlighting other key architectural updates.* ## [Unreleased] +### Fixed +- Sending zero funds is allowed only for shielded recipient address type + ## [0.2.0 (609)] - 2024-04-18 ### Added diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/SendViewTestSetup.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/SendViewTestSetup.kt index d8bfbc08..e7207977 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/SendViewTestSetup.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/SendViewTestSetup.kt @@ -128,7 +128,7 @@ class SendViewTestSetup( // TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260 }, setAmountState = {}, - amountState = AmountState.new(context, "", monetarySeparators), + amountState = AmountState.new(context, monetarySeparators, "", false), setMemoState = {}, memoState = MemoState.new(""), walletRestoringState = WalletRestoringState.NONE, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt index 8b8b70e7..c0a71fe1 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt @@ -8,6 +8,7 @@ import androidx.activity.compose.BackHandler import androidx.activity.viewModels import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -21,6 +22,7 @@ import cash.z.ecc.android.sdk.model.UnifiedSpendingKey import cash.z.ecc.android.sdk.model.ZecSend import cash.z.ecc.android.sdk.model.proposeSend import cash.z.ecc.android.sdk.model.toZecString +import cash.z.ecc.android.sdk.type.AddressType import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.ui.common.compose.BalanceState import co.electriccoin.zcash.ui.common.model.WalletRestoringState @@ -95,7 +97,7 @@ internal fun WrapSend( ) } -@Suppress("LongParameterList", "LongMethod") +@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") @VisibleForTesting @Composable internal fun WrapSend( @@ -140,14 +142,28 @@ internal fun WrapSend( // Amount computation: val (amountState, setAmountState) = rememberSaveable(stateSaver = AmountState.Saver) { + // Default amount state mutableStateOf( AmountState.new( context = context, value = zecSend?.amount?.toZecString() ?: "", - monetarySeparators = monetarySeparators + monetarySeparators = monetarySeparators, + isTransparentRecipient = recipientAddressState.type?.let { it == AddressType.Transparent } ?: false ) ) } + // New amount state based on the recipient address type (e.g. shielded supports zero funds sending and + // transparent not) + LaunchedEffect(key1 = recipientAddressState) { + setAmountState( + AmountState.new( + context = context, + isTransparentRecipient = recipientAddressState.type?.let { it == AddressType.Transparent } ?: false, + monetarySeparators = monetarySeparators, + value = amountState.value + ) + ) + } // Memo computation: val (memoState, setMemoState) = @@ -155,12 +171,12 @@ internal fun WrapSend( mutableStateOf(MemoState.new(zecSend?.memo?.value ?: "")) } - // Clearing form if required form the previous navigation destination + // Clearing form from the previous navigation destination if required if (sendArguments?.clearForm == true) { setSendStage(SendStage.Form) setZecSend(null) setRecipientAddressState(RecipientAddressState.new("", null)) - setAmountState(AmountState.new(context, "", monetarySeparators)) + setAmountState(AmountState.new(context, monetarySeparators, "", false)) setMemoState(MemoState.new("")) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/AmountState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/AmountState.kt index 85452bf7..6e7369d3 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/AmountState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/AmountState.kt @@ -23,8 +23,9 @@ sealed class AmountState( companion object { fun new( context: Context, + monetarySeparators: MonetarySeparators, value: String, - monetarySeparators: MonetarySeparators + isTransparentRecipient: Boolean ): AmountState { // Validate raw input string val validated = @@ -41,11 +42,11 @@ sealed class AmountState( // Convert the input to Zatoshi type-safe amount representation val zatoshi = (Zatoshi.fromZecString(context, value, monetarySeparators)) - // Note that the 0 funds sending is supported for sending a memo-only transaction - return if (zatoshi == null) { - Invalid(value) - } else { - Valid(value, zatoshi) + // Note that the zero funds sending is supported for sending a memo-only shielded transaction + return when { + (zatoshi == null) -> Invalid(value) + (zatoshi.value == 0L && isTransparentRecipient) -> Invalid(value) + else -> Valid(value, zatoshi) } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt index e4834357..b2859cb5 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt @@ -264,8 +264,6 @@ private fun SendForm( hasCameraFeature: Boolean, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - // TODO [#1171]: Remove default MonetarySeparators locale // TODO [#1171]: https://github.com/Electric-Coin-Company/zashi-android/issues/1171 val monetarySeparators = MonetarySeparators.current(Locale.US) @@ -305,16 +303,17 @@ private fun SendForm( SendFormAmountTextField( amountSate = amountState, - setAmountState = setAmountState, - monetarySeparators = monetarySeparators, focusManager = focusManager, - walletSnapshot = walletSnapshot, imeAction = if (recipientAddressState.type == AddressType.Transparent) { ImeAction.Done } else { ImeAction.Next - } + }, + isTransparentRecipient = recipientAddressState.type?.let { it == AddressType.Transparent } ?: false, + monetarySeparators = monetarySeparators, + setAmountState = setAmountState, + walletSnapshot = walletSnapshot, ) Spacer(Modifier.size(ZcashTheme.dimens.spacingDefault)) @@ -342,68 +341,83 @@ private fun SendForm( Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) - // Common conditions continuously checked for validity - val sendButtonEnabled = - recipientAddressState.type !is AddressType.Invalid && - recipientAddressState.address.isNotEmpty() && - amountState is AmountState.Valid && - amountState.value.isNotBlank() && - walletSnapshot.canSpend(amountState.zatoshi) && - // A valid memo is necessary only for non-transparent recipient - (recipientAddressState.type == AddressType.Transparent || memoState is MemoState.Correct) + SendButton(amountState, memoState, monetarySeparators, onCreateZecSend, recipientAddressState, walletSnapshot) + } +} - Column( - modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular), - horizontalAlignment = Alignment.CenterHorizontally - ) { - PrimaryButton( - onClick = { - // SDK side validations - val zecSendValidation = - ZecSendExt.new( - context = context, - destinationString = recipientAddressState.address, - zecString = amountState.value, - // Take memo for a valid non-transparent receiver only - memoString = - if (recipientAddressState.type == AddressType.Transparent) { - "" - } else { - memoState.text - }, - monetarySeparators = monetarySeparators - ) +@Composable +@Suppress("LongParameterList") +fun SendButton( + amountState: AmountState, + memoState: MemoState, + monetarySeparators: MonetarySeparators, + onCreateZecSend: (ZecSend) -> Unit, + recipientAddressState: RecipientAddressState, + walletSnapshot: WalletSnapshot, +) { + val context = LocalContext.current - when (zecSendValidation) { - is ZecSendExt.ZecSendValidation.Valid -> onCreateZecSend(zecSendValidation.zecSend) - is ZecSendExt.ZecSendValidation.Invalid -> { - // We do not expect this validation to fail, so logging is enough here - // An error popup could be reasonable here as well - Twig.warn { "Send failed with: ${zecSendValidation.validationErrors}" } - } + // Common conditions continuously checked for validity + val sendButtonEnabled = + recipientAddressState.type !is AddressType.Invalid && + recipientAddressState.address.isNotEmpty() && + amountState is AmountState.Valid && + amountState.value.isNotBlank() && + walletSnapshot.canSpend(amountState.zatoshi) && + // A valid memo is necessary only for non-transparent recipient + (recipientAddressState.type == AddressType.Transparent || memoState is MemoState.Correct) + + Column( + modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular), + horizontalAlignment = Alignment.CenterHorizontally + ) { + PrimaryButton( + onClick = { + // SDK side validations + val zecSendValidation = + ZecSendExt.new( + context = context, + destinationString = recipientAddressState.address, + zecString = amountState.value, + // Take memo for a valid non-transparent receiver only + memoString = + if (recipientAddressState.type == AddressType.Transparent) { + "" + } else { + memoState.text + }, + monetarySeparators = monetarySeparators + ) + + when (zecSendValidation) { + is ZecSendExt.ZecSendValidation.Valid -> onCreateZecSend(zecSendValidation.zecSend) + is ZecSendExt.ZecSendValidation.Invalid -> { + // We do not expect this validation to fail, so logging is enough here + // An error popup could be reasonable here as well + Twig.warn { "Send failed with: ${zecSendValidation.validationErrors}" } } - }, - text = stringResource(id = R.string.send_create), - enabled = sendButtonEnabled, - modifier = - Modifier - .testTag(SendTag.SEND_FORM_BUTTON) - .fillMaxWidth() - ) + } + }, + text = stringResource(id = R.string.send_create), + enabled = sendButtonEnabled, + modifier = + Modifier + .testTag(SendTag.SEND_FORM_BUTTON) + .fillMaxWidth() + ) - Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) + Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) - BodySmall( - text = - stringResource( - id = R.string.send_fee, - // TODO [#1047]: Representing Zatoshi amount - // TODO [#1047]: https://github.com/Electric-Coin-Company/zashi-android/issues/1047 - Zatoshi(DEFAULT_LESS_THAN_FEE).toZecString() - ), - textFontWeight = FontWeight.SemiBold - ) - } + BodySmall( + text = + stringResource( + id = R.string.send_fee, + // TODO [#1047]: Representing Zatoshi amount + // TODO [#1047]: https://github.com/Electric-Coin-Company/zashi-android/issues/1047 + Zatoshi(DEFAULT_LESS_THAN_FEE).toZecString() + ), + textFontWeight = FontWeight.SemiBold + ) } } @@ -496,10 +510,11 @@ fun SendFormAddressTextField( fun SendFormAmountTextField( amountSate: AmountState, focusManager: FocusManager, + imeAction: ImeAction, + isTransparentRecipient: Boolean, monetarySeparators: MonetarySeparators, setAmountState: (AmountState) -> Unit, walletSnapshot: WalletSnapshot, - imeAction: ImeAction, ) { val context = LocalContext.current @@ -540,7 +555,14 @@ fun SendFormAmountTextField( FormTextField( value = amountSate.value, onValueChange = { newValue -> - setAmountState(AmountState.new(context, newValue, monetarySeparators)) + setAmountState( + AmountState.new( + context = context, + value = newValue, + monetarySeparators = monetarySeparators, + isTransparentRecipient = isTransparentRecipient + ) + ) }, modifier = Modifier.fillMaxWidth(), error = amountError,