Updated on 2026-08-14

This commit is contained in:
Tangem 2021-02-12 11:03:11 +03:00
commit 2bb6a2b17d
28 changed files with 303 additions and 155 deletions

View file

@ -36,17 +36,17 @@ fun String.toQrCode(): Bitmap {
return bmp
}
fun BigDecimal.toFormattedString(decimals: Int): String {
val symbols = DecimalFormatSymbols(Locale.US)
symbols.decimalSeparator = '.'
fun BigDecimal.toFormattedString(
decimals: Int, roundingMode: RoundingMode = RoundingMode.DOWN, locale: Locale = Locale.US
): String {
val symbols = DecimalFormatSymbols(locale)
val df = DecimalFormat()
df.decimalFormatSymbols = symbols
df.maximumFractionDigits = decimals
df.minimumFractionDigits = 0
df.isGroupingUsed = false
val bd = BigDecimal(unscaledValue(), scale())
bd.setScale(decimals, RoundingMode.DOWN)
return df.format(bd)
df.roundingMode = roundingMode
return df.format(this)
}
fun BigDecimal.toFormattedCurrencyString(decimals: Int, currency: String): String {

View file

@ -2,6 +2,7 @@ package com.tangem.tap.common.redux.global
import com.tangem.tap.domain.config.ConfigManager
import com.tangem.tap.domain.tasks.ScanNoteResponse
import com.tangem.tap.features.details.redux.SecurityOption
import org.rekotlin.Action
import java.math.BigDecimal
@ -17,4 +18,5 @@ sealed class GlobalAction : Action {
}
data class UpdateWalletSignedHashes(val walletSignedHashes: Int?) : GlobalAction()
data class SetConfigManager(val configManager: ConfigManager) : GlobalAction()
data class UpdateSecurityOptions(val securityOption: SecurityOption) : GlobalAction()
}

View file

@ -1,6 +1,7 @@
package com.tangem.tap.common.redux.global
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.details.redux.SecurityOption
import org.rekotlin.Action
fun globalReducer(action: Action, state: AppState): GlobalState {
@ -36,6 +37,24 @@ fun globalReducer(action: Action, state: AppState): GlobalState {
is GlobalAction.SetConfigManager -> {
globalState.copy(configManager = action.configManager)
}
is GlobalAction.UpdateSecurityOptions -> {
val card = when (action.securityOption) {
SecurityOption.LongTap -> globalState.scanNoteResponse?.card?.copy(
isPin1Default = true, isPin2Default = true
)
SecurityOption.PassCode -> globalState.scanNoteResponse?.card?.copy(
isPin1Default = true, isPin2Default = false
)
SecurityOption.AccessCode -> globalState.scanNoteResponse?.card?.copy(
isPin1Default = false, isPin2Default = true
)
}
if (card != null) {
globalState.copy(scanNoteResponse = globalState.scanNoteResponse?.copy(card = card))
} else {
globalState
}
}
else -> globalState
}
}

View file

@ -38,6 +38,10 @@ sealed class TapError(
object DustChange : TapError(R.string.send_error_dust_change)
data class CreateAccountUnderfunded(override val args: List<Any>) : TapError(R.string.send_error_no_target_account)
sealed class XmlError {
object AssetAccountNotCreated: TapError(R.string.send_error_no_account_xlm)
}
data class ValidateTransactionErrors(
override val errorList: List<TapError>,
override val builder: (List<String>) -> String

View file

@ -76,7 +76,7 @@ class ScanNoteTask(val card: Card? = null) : CardSessionRunnable<ScanNoteRespons
)))
}
is CompletionResult.Failure -> {
callback(CompletionResult.Failure(TangemSdkError.VerificationFailed()))
callback(CompletionResult.Failure(TangemSdkError.CardVerificationFailed()))
}
}
}

View file

@ -140,6 +140,9 @@ class DetailsMiddleware {
withContext(Dispatchers.Main) {
when (result) {
is CompletionResult.Success -> {
selectedOption?.let {
store.dispatch(GlobalAction.UpdateSecurityOptions(it))
}
if (selectedOption != SecurityOption.LongTap) {
store.dispatch(NavigationAction.PopBackTo())
}

View file

@ -144,23 +144,8 @@ private fun handleSecurityAction(
allowedOptions = EnumSet.noneOf(SecurityOption::class.java)
))
}
val prohibitDefaultPin = state.card.settingsMask?.contains(Settings.ProhibitDefaultPIN1) == true
val allowSetPin1 = state.card.settingsMask?.contains(Settings.AllowSetPIN1) != false
val allowSetPin2 = state.card.settingsMask?.contains(Settings.AllowSetPIN2) != false
val isDefaultPin1 = state.card.isPin1Default != false
val isDefaultPin2 = state.card.isPin2Default != false
val allowedSecurityOptions = EnumSet.noneOf(SecurityOption::class.java)
if ((isDefaultPin1 && isDefaultPin2) || !prohibitDefaultPin) {
allowedSecurityOptions.add(SecurityOption.LongTap)
}
if (allowSetPin1 && (isDefaultPin2 || !prohibitDefaultPin)) {
allowedSecurityOptions.add(SecurityOption.AccessCode)
}
if (allowSetPin2 && (isDefaultPin1 || !prohibitDefaultPin)) {
allowedSecurityOptions.add(SecurityOption.PassCode)
}
val allowedSecurityOptions = prepareAllowedSecurityOptions(state.card)
state.copy(securityScreenState = state.securityScreenState?.copy(
allowedOptions = allowedSecurityOptions,
selectedOption = state.securityScreenState.currentOption
@ -180,15 +165,37 @@ private fun handleSecurityAction(
state.copy(confirmScreenState = confirmScreenState)
}
is DetailsAction.ManageSecurity.SaveChanges.Success -> {
state.copy(securityScreenState = state.securityScreenState?.copy(
currentOption = state.securityScreenState.selectedOption
))
// Setting options to show only LongTap from now on
state.copy(
card = state.card?.copy(isPin1Default = true, isPin2Default = true),
securityScreenState = state.securityScreenState?.copy(
currentOption = state.securityScreenState.selectedOption,
allowedOptions = EnumSet.of(SecurityOption.LongTap)
))
}
else -> state
}
}
private fun prepareAllowedSecurityOptions(card: Card): EnumSet<SecurityOption> {
val prohibitDefaultPin = card.settingsMask?.contains(Settings.ProhibitDefaultPIN1) == true
val isDefaultPin1 = card.isPin1Default != false
val isDefaultPin2 = card.isPin2Default != false
val allowedSecurityOptions = EnumSet.noneOf(SecurityOption::class.java)
if ((isDefaultPin1 && isDefaultPin2) || !prohibitDefaultPin) {
allowedSecurityOptions.add(SecurityOption.LongTap)
}
if (!isDefaultPin1) {
allowedSecurityOptions.add(SecurityOption.AccessCode)
}
if (!isDefaultPin2) {
allowedSecurityOptions.add(SecurityOption.PassCode)
}
return allowedSecurityOptions
}
private fun Card.toCardInfo(): CardInfo? {
val cardId = this.cardId.chunked(4).joinToString(separator = " ")

View file

@ -5,6 +5,7 @@ import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.transition.TransitionInflater
import com.tangem.tap.common.extensions.show
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.features.details.redux.DetailsAction
import com.tangem.tap.features.details.redux.DetailsState
@ -91,6 +92,7 @@ class DetailsSecurityFragment : Fragment(R.layout.fragment_details_security),
}
private fun enableLongTap(enable: Boolean) {
group_long_tap.show(enable)
val alpha = if (enable) 1f else 0.5f
tv_long_tap_description.alpha = alpha
tv_long_tap_title.alpha = alpha
@ -105,6 +107,7 @@ class DetailsSecurityFragment : Fragment(R.layout.fragment_details_security),
}
private fun enablePasscode(enable: Boolean) {
group_passcode.show(enable)
val alpha = if (enable) 1f else 0.5f
tv_passcode_description.alpha = alpha
tv_passcode_title.alpha = alpha
@ -119,6 +122,7 @@ class DetailsSecurityFragment : Fragment(R.layout.fragment_details_security),
}
private fun enableAccessCode(enable: Boolean) {
group_access_code.show(enable)
val alpha = if (enable) 1f else 0.5f
tv_access_code_description.alpha = alpha
tv_access_code_title.alpha = alpha

View file

@ -98,7 +98,9 @@ class CreateTwinWalletFragment : Fragment(R.layout.fragment_details_twin_cards),
btn_tap.setOnClickListener {
store.dispatch(DetailsAction.CreateTwinWalletAction.LaunchFirstStep(
Message(getString(
R.string.details_twins_recreate_title_format, twinCardNumber)
R.string.details_twins_recreate_title_format,
twinCardNumberString
)
)))
}
btn_tap.text = getString(R.string.details_twins_recreate_button_format,

View file

@ -110,7 +110,24 @@ internal class AddressPayIdMiddleware {
}
private fun verifyAddress(address: String, wallet: Wallet, isUserInput: Boolean, dispatch: (Action) -> Unit) {
val supposedAddress = extractAddressFromShareUri(address).removeNonAddressData()
val addressSchemeSplit = address.split(":")
val noSchemeAddress = when (addressSchemeSplit.size) {
1 -> address // no scheme
2 -> { // scheme
if (wallet.blockchain.validateShareScheme(addressSchemeSplit[0])) {
addressSchemeSplit[1]
} else {
dispatch(SetAddressError(Error.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN))
return
}
}
else -> { // invalid URI
dispatch(SetAddressError(Error.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN))
return
}
}
val supposedAddress = noSchemeAddress.removeShareUriQuery() //TODO: parse query?
val failReason = isValidBlockchainAddressAndNotTheSameAsWallet(wallet, supposedAddress)
if (failReason == null) {
@ -141,7 +158,7 @@ internal class AddressPayIdMiddleware {
return if (prefixes.isEmpty()) shareUri else shareUri.replace(prefixes[0], "")
}
private fun String.removeNonAddressData(): String = this.substringBefore("?")
private fun String.removeShareUriQuery(): String = this.substringBefore("?")
private fun verifyClipboard(input: String?, appState: AppState?, dispatch: DispatchFunction) {
val addressPayId = input ?: return

View file

@ -140,6 +140,11 @@ private fun sendTransaction(
message.contains("50002") -> {
// user was cancelled the operation by closing the Sdk bottom sheet
}
// make it easier latter by handling an appropriate enumError or, like on iOS,
// accept a string identifier of the error message
message.contains("Target account is not created. To create account send 1+ XLM.")-> {
dispatch(SendAction.SendError(TapError.XmlError.AssetAccountNotCreated))
}
else -> {
Timber.e(throwable)
FirebaseCrashlytics.getInstance().recordException(throwable)

View file

@ -24,8 +24,9 @@ class TransactionExtrasReducer : SendInternalReducer {
val emptyResult = TransactionExtrasState()
val result = when (action.blockchain) {
Blockchain.XRP -> {
val address = action.walletAddress.substringAfter(":")
// 'r' - without tag, 'x' - with tag
if (action.walletAddress.startsWith("r", true)) {
if (address.startsWith("r", true)) {
val tag = action.xrpTag?.toLongOrNull()
if (tag == null) {
TransactionExtrasState(xrpDestinationTag = XrpDestinationTagState())
@ -54,7 +55,7 @@ class TransactionExtrasReducer : SendInternalReducer {
sendState: SendState,
infoState: TransactionExtrasState,
): SendState {
fun clearMemo(memo: XlmMemoState): XlmMemoState = memo.copy(text = null, id = null)
fun clearMemo(memo: XlmMemoState): XlmMemoState = memo.copy(text = null, id = null, error = null)
val result = when (action) {
// is XlmMemo.ChangeSelectedMemo -> {
@ -68,13 +69,22 @@ class TransactionExtrasReducer : SendInternalReducer {
// }
is XlmMemo.HandleUserInput -> {
val inputViewValue = InputViewValue(action.data, true)
var memo = infoState.xlmMemo?.copy(viewFieldValue = inputViewValue) ?: XlmMemoState(inputViewValue)
var memo = infoState.xlmMemo?.copy(viewFieldValue = inputViewValue)
?: XlmMemoState(inputViewValue)
memo = clearMemo(memo)
memo = when (infoState.xlmMemo?.selectedMemoType) {
XlmMemoType.TEXT -> memo.copy(text = StellarMemo.Text(action.data))
XlmMemoType.ID -> {
val id = action.data.toIntOrNull()?.toBigInteger()
if (id != null) memo.copy(id = StellarMemo.Id(id)) else memo
val id = action.data.toBigIntegerOrNull()
if (id != null) {
if (id > XlmMemoState.MAX_NUMBER) {
memo.copy(error = TransactionExtraError.INVALID_XLM_MEMO)
} else {
memo.copy(id = StellarMemo.Id(id))
}
} else {
memo
}
}
null -> memo
}
@ -93,7 +103,12 @@ class TransactionExtrasReducer : SendInternalReducer {
is XrpDestinationTag.HandleUserInput -> {
val tag = action.data.toLongOrNull()
if (tag != null) {
val tagState = XrpDestinationTagState(InputViewValue(action.data, true), tag)
val input = InputViewValue(action.data, true)
val tagState = if (tag <= XrpDestinationTagState.MAX_NUMBER){
XrpDestinationTagState(input, tag)
} else {
XrpDestinationTagState(input, error = TransactionExtraError.INVALID_DESTINATION_TAG)
}
infoState.copy(xrpDestinationTag = tagState)
} else {
infoState

View file

@ -2,6 +2,7 @@ package com.tangem.tap.features.send.redux.states
import com.tangem.blockchain.blockchains.stellar.StellarMemo
import com.tangem.tap.features.send.redux.AddressPayIdVerifyAction
import java.math.BigInteger
data class AddressPayIdState(
val viewFieldValue: InputViewValue = InputViewValue(""),
@ -39,16 +40,31 @@ data class XlmMemoState(
val selectedMemoType: XlmMemoType = XlmMemoType.ID,
val text: StellarMemo.Text? = null,
val id: StellarMemo.Id? = null,
val error: TransactionExtraError? = null,
) {
val memo: StellarMemo?
get() = when (selectedMemoType) {
XlmMemoType.TEXT -> text
XlmMemoType.ID -> id
}
companion object {
val MAX_NUMBER: BigInteger = BigInteger("FFFFFFFFFFFFFFFF", 16)
}
}
// tag must contains only digits
data class XrpDestinationTagState(
val viewFieldValue: InputViewValue = InputViewValue(""),
val tag: Long? = null
)
val tag: Long? = null,
val error: TransactionExtraError? = null
) {
companion object {
const val MAX_NUMBER: Long = 4294967295
}
}
enum class TransactionExtraError {
INVALID_DESTINATION_TAG,
INVALID_XLM_MEMO
}

View file

@ -70,9 +70,25 @@ class SendStateSubscriber(fragment: BaseStoreFragment) : FragmentStateSubscriber
XlmMemoType.ID -> InputType.TYPE_CLASS_NUMBER
}
if (!it.viewFieldValue.isFromUserInput) fg.etMemo.setText(it.viewFieldValue.value)
if (it.error != null) {
if (it.error == TransactionExtraError.INVALID_XLM_MEMO) {
fg.tilMemo.error = fg.getText(R.string.send_error_invalid_memo_id)
}
} else {
fg.tilMemo.error = null
}
}
infoState.xrpDestinationTag?.let {
if (!it.viewFieldValue.isFromUserInput) fg.etDestinationTag.setText(it.viewFieldValue.value)
if (infoState.xrpDestinationTag.error != null) {
if (infoState.xrpDestinationTag.error == TransactionExtraError.INVALID_DESTINATION_TAG) {
fg.tilDestinationTag.error = fg.getText(R.string.send_error_invalid_destination_tag)
}
} else {
fg.tilDestinationTag.error = null
}
if (!it.viewFieldValue.isFromUserInput) {
fg.etDestinationTag.setText(it.viewFieldValue.value)
}
}
}

View file

@ -32,11 +32,13 @@ data class WalletState(
currencyData.status != com.tangem.tap.features.wallet.ui.BalanceStatus.EmptyCard &&
currencyData.status != com.tangem.tap.features.wallet.ui.BalanceStatus.UnknownBlockchain
val showSegwitAddress: Boolean
val showMultipleAddress: Boolean
get() {
val listOfAddresses = walletAddresses?.list ?: return false
return (wallet?.blockchain == Blockchain.Bitcoin || wallet?.blockchain == Blockchain.BitcoinTestnet)
&& listOfAddresses.size > 1
return (wallet?.blockchain == Blockchain.Bitcoin ||
wallet?.blockchain == Blockchain.BitcoinTestnet ||
wallet?.blockchain == Blockchain.CardanoShelley) &&
listOfAddresses.size > 1
}
}

View file

@ -12,6 +12,8 @@ import androidx.transition.TransitionInflater
import com.google.android.material.snackbar.Snackbar
import com.squareup.picasso.Picasso
import com.tangem.blockchain.blockchains.bitcoin.BitcoinAddressType
import com.tangem.blockchain.blockchains.cardano.CardanoAddressType
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.address.AddressType
import com.tangem.tap.common.extensions.*
import com.tangem.tap.common.redux.navigation.AppScreen
@ -228,28 +230,23 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
private fun setupAddressCard(state: WalletState) {
if (state.walletAddresses != null) {
l_address?.show()
val tvAddressPaddingTop = tv_address.resources.getDimension(R.dimen.dimen16).toInt()
if (state.showSegwitAddress) {
if (state.showMultipleAddress) {
(l_address as? ViewGroup)?.beginDelayedTransition()
tv_address.setPadding(tv_address.paddingStart, tvAddressPaddingTop / 2,
tv_address.paddingEnd, tv_address.paddingBottom)
chip_group_segwit.show()
chip_group_segwit.fitChipsByGroupWidth()
chip_group_address_type.show()
chip_group_address_type.fitChipsByGroupWidth()
val checkedId = SegwitUiHelper.typeToId(state.walletAddresses.selectedAddress.type)
if (checkedId != View.NO_ID) chip_group_segwit.check(checkedId)
val checkedId = MultipleAddressUiHelper.typeToId(state.walletAddresses.selectedAddress.type)
if (checkedId != View.NO_ID) chip_group_address_type.check(checkedId)
chip_group_segwit.setOnCheckedChangeListener { group, checkedId ->
chip_group_address_type.setOnCheckedChangeListener { group, checkedId ->
if (checkedId == -1) return@setOnCheckedChangeListener
SegwitUiHelper.idToType(checkedId)?.let {
MultipleAddressUiHelper.idToType(checkedId, state.wallet?.blockchain)?.let {
store.dispatch(WalletAction.ChangeSelectedAddress(it))
}
}
} else {
tv_address.setPadding(tv_address.paddingStart, tvAddressPaddingTop,
tv_address.paddingEnd, tv_address.paddingBottom)
chip_group_segwit.hide()
chip_group_address_type.hide()
}
tv_address.text = state.walletAddresses.selectedAddress.address
tv_explore?.setOnClickListener {
@ -332,20 +329,34 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
}
class SegwitUiHelper {
class MultipleAddressUiHelper {
companion object {
fun typeToId(type: AddressType): Int {
return when (type) {
is BitcoinAddressType.Legacy -> R.id.chip_legacy
is BitcoinAddressType.Segwit -> R.id.chip_default
is CardanoAddressType.Byron -> R.id.chip_legacy
is CardanoAddressType.Shelley -> R.id.chip_default
else -> View.NO_ID
}
}
fun idToType(id: Int): AddressType? {
fun idToType(id: Int, blockchain: Blockchain?): AddressType? {
return when (id) {
R.id.chip_default -> BitcoinAddressType.Segwit
R.id.chip_legacy -> BitcoinAddressType.Legacy
R.id.chip_default -> {
when (blockchain) {
Blockchain.Bitcoin -> BitcoinAddressType.Segwit
Blockchain.CardanoShelley -> CardanoAddressType.Shelley
else -> null
}
}
R.id.chip_legacy -> {
when (blockchain) {
Blockchain.Bitcoin -> BitcoinAddressType.Legacy
Blockchain.CardanoShelley -> CardanoAddressType.Byron
else -> null
}
}
else -> null
}
}