From a38c36ce2db66ab30d88df2902748238145a6118 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 12 Apr 2022 16:17:06 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../com/tangem/tap/common/redux/AppState.kt | 4 +- .../common/redux/global/GlobalMidlleware.kt | 4 +- .../tangem/domain/{common => }/DomainError.kt | 11 +- .../com/tangem/domain/DomainStateDialog.kt | 19 + .../tangem/domain/common/TapWorkarounds.kt | 10 - .../domain/common/form/FieldDataConverters.kt | 40 ++ .../domain/common/form/FieldsValidators.kt | 37 +- .../com/tangem/domain/common/form/Form.kt | 67 +--- .../common/{ => util}/ValueDebouncer.kt | 7 +- .../AddCustomTokenStatePrinter.kt | 63 +++ .../features/addCustomToken/CompleteData.kt | 59 +++ .../domain/features/addCustomToken/Errors.kt | 20 +- .../features/addCustomToken/FormFields.kt | 20 +- .../redux/AddCustomTokenAction.kt | 38 +- .../addCustomToken/redux/AddCustomTokenHub.kt | 368 ++++++++++++++---- .../redux/AddCustomTokenState.kt | 117 ++++++ .../redux/AddCustomTokensState.kt | 137 ------- .../features/addCustomToken/redux/Models.kt | 31 ++ .../com/tangem/domain/redux/DomainState.kt | 13 + .../com/tangem/domain/redux/DomainStore.kt | 33 ++ .../com/tangem/domain/redux/ReStoreHub.kt | 107 +++++ .../global}/DomainGlobalAction.kt | 4 +- .../redux => redux/global}/DomainGlobalHub.kt | 19 +- .../global}/DomainGlobalState.kt | 4 +- .../domain/redux/state/StateObservables.kt | 36 ++ .../tangem/domain/redux/state/StatePrinter.kt | 12 + .../com/tangem/domain/store/DomainStore.kt | 37 -- .../java/com/tangem/domain/store/StoreHub.kt | 87 ----- 28 files changed, 940 insertions(+), 464 deletions(-) rename domain/src/main/java/com/tangem/domain/{common => }/DomainError.kt (57%) create mode 100644 domain/src/main/java/com/tangem/domain/DomainStateDialog.kt create mode 100644 domain/src/main/java/com/tangem/domain/common/form/FieldDataConverters.kt rename domain/src/main/java/com/tangem/domain/common/{ => util}/ValueDebouncer.kt (86%) create mode 100644 domain/src/main/java/com/tangem/domain/features/addCustomToken/AddCustomTokenStatePrinter.kt create mode 100644 domain/src/main/java/com/tangem/domain/features/addCustomToken/CompleteData.kt create mode 100644 domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenState.kt delete mode 100644 domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokensState.kt create mode 100644 domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/Models.kt create mode 100644 domain/src/main/java/com/tangem/domain/redux/DomainState.kt create mode 100644 domain/src/main/java/com/tangem/domain/redux/DomainStore.kt create mode 100644 domain/src/main/java/com/tangem/domain/redux/ReStoreHub.kt rename domain/src/main/java/com/tangem/domain/{features/global/redux => redux/global}/DomainGlobalAction.kt (62%) rename domain/src/main/java/com/tangem/domain/{features/global/redux => redux/global}/DomainGlobalHub.kt (63%) rename domain/src/main/java/com/tangem/domain/{features/global/redux => redux/global}/DomainGlobalState.kt (61%) create mode 100644 domain/src/main/java/com/tangem/domain/redux/state/StateObservables.kt create mode 100644 domain/src/main/java/com/tangem/domain/redux/state/StatePrinter.kt delete mode 100644 domain/src/main/java/com/tangem/domain/store/DomainStore.kt delete mode 100644 domain/src/main/java/com/tangem/domain/store/StoreHub.kt diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt index f7f061dc06..ac955b82a7 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt @@ -1,7 +1,7 @@ package com.tangem.tap.common.redux -import com.tangem.domain.restore.DomainState -import com.tangem.domain.restore.domainStore +import com.tangem.domain.redux.DomainState +import com.tangem.domain.redux.domainStore import com.tangem.tap.common.redux.global.GlobalMiddleware import com.tangem.tap.common.redux.global.GlobalState import com.tangem.tap.common.redux.navigation.NavigationState diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMidlleware.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMidlleware.kt index 9407ceff77..7e1cb3f0cc 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMidlleware.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMidlleware.kt @@ -4,8 +4,8 @@ import com.tangem.common.CompletionResult import com.tangem.common.core.TangemSdkError import com.tangem.common.extensions.ifNotNull import com.tangem.domain.common.extensions.withMainContext -import com.tangem.domain.features.global.redux.DomainGlobalAction -import com.tangem.domain.restore.domainStore +import com.tangem.domain.redux.domainStore +import com.tangem.domain.redux.global.DomainGlobalAction import com.tangem.tap.* import com.tangem.tap.common.extensions.dispatchDialogShow import com.tangem.tap.common.extensions.dispatchOnMain diff --git a/domain/src/main/java/com/tangem/domain/common/DomainError.kt b/domain/src/main/java/com/tangem/domain/DomainError.kt similarity index 57% rename from domain/src/main/java/com/tangem/domain/common/DomainError.kt rename to domain/src/main/java/com/tangem/domain/DomainError.kt index 67bc6d289c..ad0115427a 100644 --- a/domain/src/main/java/com/tangem/domain/common/DomainError.kt +++ b/domain/src/main/java/com/tangem/domain/DomainError.kt @@ -1,7 +1,10 @@ -package com.tangem.domain.common +package com.tangem.domain /** [REDACTED_AUTHOR] + * @property code describes what feature is the error coming from + * @property message the error description + * @property data any data that can help in the part where this error is being handled */ interface DomainError { val code: Int @@ -9,7 +12,7 @@ interface DomainError { val data: Any? } -open class AnyError( +open class AnError( override val code: Int, override val message: String, override val data: Any? = null, @@ -21,4 +24,6 @@ interface ErrorConverter { interface Validator { fun validate(data: Data? = null): Error? -} \ No newline at end of file +} + +const val ERROR_CODE_ADD_CUSTOM_TOKEN = 100 \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/DomainStateDialog.kt b/domain/src/main/java/com/tangem/domain/DomainStateDialog.kt new file mode 100644 index 0000000000..c6baa9a431 --- /dev/null +++ b/domain/src/main/java/com/tangem/domain/DomainStateDialog.kt @@ -0,0 +1,19 @@ +package com.tangem.domain + +import com.tangem.common.extensions.VoidCallback +import com.tangem.network.api.tangemTech.Coins + +/** +[REDACTED_AUTHOR] + */ +interface DomainStateDialog + +sealed class DomainDialog : DomainStateDialog { + + data class SelectTokenDialog( + val items: List, + val itemNameConverter: (Coins.CheckAddressResponse.Token.Contract) -> String, + val onSelect: (Coins.CheckAddressResponse.Token.Contract) -> Unit, + val onClose: VoidCallback = {} + ) : DomainDialog() +} \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/common/TapWorkarounds.kt b/domain/src/main/java/com/tangem/domain/common/TapWorkarounds.kt index 661602b270..61754302fb 100644 --- a/domain/src/main/java/com/tangem/domain/common/TapWorkarounds.kt +++ b/domain/src/main/java/com/tangem/domain/common/TapWorkarounds.kt @@ -41,12 +41,6 @@ object TapWorkarounds { fun Card.getTangemNoteBlockchain(): Blockchain? = tangemNoteBatches[batchId] - val Card.derivationType: DerivationType - get() = when { - tangemWalletBatchesWithStandardDerivationType.contains(batchId) -> DerivationType.Standard - else -> DerivationType.Metamask - } - val Card.isStart2Coin: Boolean get() = isStart2CoinIssuer(issuer.name) @@ -71,8 +65,4 @@ object TapWorkarounds { fun isStart2CoinIssuer(cardIssuer: String?): Boolean { return cardIssuer?.toLowerCase(Locale.US) == START_2_COIN_ISSUER } -} - -enum class DerivationType { - Metamask, Standard } \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/common/form/FieldDataConverters.kt b/domain/src/main/java/com/tangem/domain/common/form/FieldDataConverters.kt new file mode 100644 index 0000000000..a0cd94a04f --- /dev/null +++ b/domain/src/main/java/com/tangem/domain/common/form/FieldDataConverters.kt @@ -0,0 +1,40 @@ +package com.tangem.domain.common.form + +import com.tangem.common.json.MoshiJsonConverter + +/** +[REDACTED_AUTHOR] + */ +interface DataConverterVisitor { + fun visit(data: Data?) + fun getConvertedData(): Result +} + +interface FieldDataConverter : DataConverterVisitor + +abstract class BaseFieldDataConverter() : FieldDataConverter { + protected val collectIds: List + get() = getIdToCollect() + + protected val collectedData: MutableMap = mutableMapOf() + + override fun visit(data: Pair>?) { + val id = data?.first ?: return + + if (collectIds.contains(id)) { + collectedData[id] = data.second.value + } + } + + abstract fun getIdToCollect(): List +} + +class FieldToJsonConverter( + private val fieldsToConvert: List = listOf(), + protected val jsonConverter: MoshiJsonConverter +) : BaseFieldDataConverter() { + + override fun getConvertedData(): String = jsonConverter.toJson(collectedData, " ") + + override fun getIdToCollect(): List = fieldsToConvert +} \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/common/form/FieldsValidators.kt b/domain/src/main/java/com/tangem/domain/common/form/FieldsValidators.kt index 94127dfdc2..ff2d9398e7 100644 --- a/domain/src/main/java/com/tangem/domain/common/form/FieldsValidators.kt +++ b/domain/src/main/java/com/tangem/domain/common/form/FieldsValidators.kt @@ -2,9 +2,7 @@ package com.tangem.domain.common.form import com.tangem.blockchain.blockchains.ethereum.EthereumAddressService import com.tangem.blockchain.common.Blockchain -import com.tangem.common.hdWallet.DerivationPath -import com.tangem.common.hdWallet.HDWalletError -import com.tangem.domain.common.Validator +import com.tangem.domain.Validator import com.tangem.domain.features.addCustomToken.AddCustomTokenError /** @@ -27,18 +25,16 @@ class StringIsNotEmptyValidator : CustomTokenValidator() { } class TokenContractAddressValidator : CustomTokenValidator() { + override fun validate(data: String?): AddCustomTokenError? { + if (data == null || data.isEmpty()) return null - override fun validate(data: String?): AddCustomTokenError? = when { -// data == null || data.isEmpty() -> AddCustomTokenError.FieldIsEmpty - else -> EthAddressValidator().validate(data) - } - - private class EthAddressValidator : CustomTokenValidator() { - override fun validate(data: String?): AddCustomTokenError? { - val isValid = EthereumAddressService().validate(data ?: "") - return if (isValid) null else AddCustomTokenError.InvalidContractAddress + return if (EthereumAddressService().validate(data)) { + null + } else { + AddCustomTokenError.InvalidContractAddress } } + } class TokenNetworkValidator : CustomTokenValidator() { @@ -48,16 +44,13 @@ class TokenNetworkValidator : CustomTokenValidator() { } } -class DerivationPathValidator : CustomTokenValidator() { - override fun validate(data: String?): AddCustomTokenError? = when { - data == null || data.isEmpty() -> null - else -> { - try { - DerivationPath(data) - null - } catch (ex: HDWalletError) { - AddCustomTokenError.InvalidDerivationPath - } +class TokenDecimalsValidator : CustomTokenValidator() { + override fun validate(data: String?): AddCustomTokenError? { + val decimal = data?.toIntOrNull() ?: return AddCustomTokenError.FieldIsEmpty + + return when { + decimal > 30 -> AddCustomTokenError.InvalidDecimalsCount + else -> null } } } \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/common/form/Form.kt b/domain/src/main/java/com/tangem/domain/common/form/Form.kt index cd0a0ea261..5788f13b1e 100644 --- a/domain/src/main/java/com/tangem/domain/common/form/Form.kt +++ b/domain/src/main/java/com/tangem/domain/common/form/Form.kt @@ -1,7 +1,5 @@ package com.tangem.domain.common.form -import com.tangem.common.json.MoshiJsonConverter - /** [REDACTED_AUTHOR] */ @@ -13,63 +11,38 @@ class Form( fun getData(id: FieldId): Pair? = getField(id)?.getData() // convert this form data whatever you want - fun getData(converter: FieldDataConverter<*>) { + fun visitDataConverter(converter: FieldDataConverter<*>) { fieldList.forEach { it.visitDataConverter(converter) } } } interface FieldId -interface Field { +interface Field { val id: FieldId - var value: Data - val isEnabled: Boolean - val isVisible: Boolean + var data: Data + + data class Data( + val value: Data, + val isUserInput: Boolean = true + ) } -abstract class BaseDataField( - override val id: FieldId, - override var value: Data -) : DataField { +typealias FieldData = Pair> - override fun getData(): Pair = id to value +interface DataField : Field { + fun getData(): Pair> + fun visitDataConverter(dataConverter: FieldDataConverter<*>) +} + +abstract class BaseDataField( + override val id: FieldId, + override var data: Field.Data, +) : DataField { + + override fun getData(): Pair> = id to data override fun visitDataConverter(dataConverter: FieldDataConverter<*>) { dataConverter.visit(getData()) } } - -interface FieldDataConverter : DataConverterVisitor, Result> - -abstract class BaseFieldDataConverter() : FieldDataConverter { - protected val collectIds: List = getIdToCollect() - - protected val collectedData: MutableMap = mutableMapOf() - - override fun visit(data: Pair?) { - val id = data?.first ?: return - - if (collectIds.contains(id)) { - collectedData[id] = data.second - } - } - - abstract fun getIdToCollect(): List -} - -abstract class FieldToJsonConverter( - protected val jsonConverter: MoshiJsonConverter -) : BaseFieldDataConverter() { - - override fun getConvertedData(): String = jsonConverter.toJson(collectedData) -} - -interface DataConverterVisitor { - fun visit(data: Visitor?) - fun getConvertedData(): Result -} - -interface DataField : Field { - fun getData(): Pair - fun visitDataConverter(dataConverter: FieldDataConverter<*>) -} \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/common/ValueDebouncer.kt b/domain/src/main/java/com/tangem/domain/common/util/ValueDebouncer.kt similarity index 86% rename from domain/src/main/java/com/tangem/domain/common/ValueDebouncer.kt rename to domain/src/main/java/com/tangem/domain/common/util/ValueDebouncer.kt index c51c711131..36dd4441f4 100644 --- a/domain/src/main/java/com/tangem/domain/common/ValueDebouncer.kt +++ b/domain/src/main/java/com/tangem/domain/common/util/ValueDebouncer.kt @@ -1,21 +1,20 @@ -package com.tangem.domain.common +package com.tangem.domain.common.util import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import timber.log.Timber /** [REDACTED_AUTHOR] */ class ValueDebouncer( - var value: T?, private val debounce: Long = 400, private val onValueChanged: (T?) -> Unit ) { + private var value: T? = null private val debounceScope = CoroutineScope(Job() + Dispatchers.Main) private val flow = MutableStateFlow(value) @@ -28,7 +27,7 @@ class ValueDebouncer( flow.filter { if (value == null) true else value != it } .debounce(debounce) .onEach { - Timber.d("onValueChanged: $it") + value = it onValueChanged(it) } .collect() diff --git a/domain/src/main/java/com/tangem/domain/features/addCustomToken/AddCustomTokenStatePrinter.kt b/domain/src/main/java/com/tangem/domain/features/addCustomToken/AddCustomTokenStatePrinter.kt new file mode 100644 index 0000000000..04365d6c82 --- /dev/null +++ b/domain/src/main/java/com/tangem/domain/features/addCustomToken/AddCustomTokenStatePrinter.kt @@ -0,0 +1,63 @@ +package com.tangem.domain.features.addCustomToken + +import com.tangem.common.json.MoshiJsonConverter +import com.tangem.domain.common.form.FieldToJsonConverter +import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction +import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState +import com.tangem.domain.redux.DomainState +import com.tangem.domain.redux.state.StatePrinter +import org.rekotlin.Action + +class AddCustomTokenStatePrinter : StatePrinter { + private val jsonConverter: MoshiJsonConverter = MoshiJsonConverter.INSTANCE + private var builder: StringBuilder = StringBuilder() + + override fun print(action: Action, domainState: DomainState): String? { + val action = (action as? AddCustomTokenAction) ?: return null + val state = domainState.addCustomTokensState + + val fieldConverter = FieldToJsonConverter(listOf( + CustomTokenFieldId.ContractAddress, + CustomTokenFieldId.Network, + CustomTokenFieldId.Name, + CustomTokenFieldId.Symbol, + CustomTokenFieldId.Decimals, + CustomTokenFieldId.DerivationPath, + ), jsonConverter) + state.visitDataConverter(fieldConverter) + val errors = state.formErrors.map { + "${it.key}: ${it.value::class.java.simpleName}" + } + val warnings = state.warnings.map { it::class.java.simpleName } + + printAction(action, state) + printStateValue("fields", fieldConverter.getConvertedData()) + printStateValue("fieldErrors", toJson(errors)) + printStateValue("warnings", toJson(warnings)) + printStateValue("screenState", toJson(state.screenState)) + printMessage("------------------------------------------------------") + + val printed = builder.toString() + builder = StringBuilder() + + return printed + } + + override fun getStateObject(domainState: DomainState): AddCustomTokenState = domainState.addCustomTokensState + + private fun printStateValue(name: String, value: String) { + printMessage("$name: $value") + } + + private fun printAction(action: AddCustomTokenAction, state: AddCustomTokenState) { + printMessage("action: $action, state: ${state::class.java.simpleName}") + } + + private fun toJson(value: Any): String { + return jsonConverter.prettyPrint(value) + } + + private fun printMessage(message: String) { + builder.append("$message\n") + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/features/addCustomToken/CompleteData.kt b/domain/src/main/java/com/tangem/domain/features/addCustomToken/CompleteData.kt new file mode 100644 index 0000000000..9f9c4af6f7 --- /dev/null +++ b/domain/src/main/java/com/tangem/domain/features/addCustomToken/CompleteData.kt @@ -0,0 +1,59 @@ +package com.tangem.domain.features.addCustomToken + +import com.tangem.blockchain.common.Blockchain +import com.tangem.domain.common.form.BaseFieldDataConverter +import com.tangem.domain.common.form.FieldDataConverter +import com.tangem.domain.common.form.FieldId +import com.tangem.domain.features.addCustomToken.redux.CompleteDataType + +/** +[REDACTED_AUTHOR] + */ +sealed class CompleteData() { + + companion object { + fun createDataConverter(completeDataType: CompleteDataType): FieldDataConverter = + when (completeDataType) { + CompleteDataType.Blockchain -> CustomBlockchain.Converter() + CompleteDataType.Token -> CustomToken.Converter() + } + } + + class CustomBlockchain( + val selectedNetwork: Blockchain, + val derivationPath: String? + ) : CompleteData() { + + class Converter : BaseFieldDataConverter() { + override fun getConvertedData(): CustomBlockchain = CustomBlockchain( + collectedData[CustomTokenFieldId.Network] as Blockchain, + collectedData[CustomTokenFieldId.DerivationPath] as? String, + ) + + override fun getIdToCollect(): List = listOf(CustomTokenFieldId.Network, CustomTokenFieldId.DerivationPath) + } + } + + class CustomToken( + val contractAddress: String, + val selectedNetwork: Blockchain, + val name: String, + val tokenSymbol: String, + val decimals: Int, + val derivationPath: String?, + ) : CompleteData() { + + class Converter : BaseFieldDataConverter() { + override fun getConvertedData(): CustomToken = CustomToken( + collectedData[CustomTokenFieldId.ContractAddress] as String, + collectedData[CustomTokenFieldId.Network] as Blockchain, + collectedData[CustomTokenFieldId.Name] as String, + collectedData[CustomTokenFieldId.Symbol] as String, + collectedData[CustomTokenFieldId.Decimals] as Int, + collectedData[CustomTokenFieldId.DerivationPath] as? String, + ) + + override fun getIdToCollect(): List = CustomTokenFieldId.values().toList() + } + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/features/addCustomToken/Errors.kt b/domain/src/main/java/com/tangem/domain/features/addCustomToken/Errors.kt index 3503402fa8..c7abc4c0d2 100644 --- a/domain/src/main/java/com/tangem/domain/features/addCustomToken/Errors.kt +++ b/domain/src/main/java/com/tangem/domain/features/addCustomToken/Errors.kt @@ -1,19 +1,21 @@ package com.tangem.domain.features.addCustomToken -import com.tangem.domain.common.AnyError +import com.tangem.domain.AnError +import com.tangem.domain.ERROR_CODE_ADD_CUSTOM_TOKEN /** [REDACTED_AUTHOR] */ -sealed class AddCustomTokenWarning : AnyError(0, "Add custom token - warning") { - object PotentialScamToken : AddCustomTokenWarning() - object TokenAlreadyAdded : AddCustomTokenWarning() -} - -sealed class AddCustomTokenError : AnyError(1, "Add custom token - error") { - object NetworkIsNotSelected : AddCustomTokenError() - object InvalidContractAddress : AddCustomTokenError() +sealed class AddCustomTokenError : AnError(ERROR_CODE_ADD_CUSTOM_TOKEN, "Add custom token - error") { object FieldIsEmpty : AddCustomTokenError() object FieldIsNotEmpty : AddCustomTokenError() + object InvalidContractAddress : AddCustomTokenError() + object NetworkIsNotSelected : AddCustomTokenError() + object InvalidDecimalsCount : AddCustomTokenError() object InvalidDerivationPath : AddCustomTokenError() +} + +sealed class AddCustomTokenWarning : AnError(ERROR_CODE_ADD_CUSTOM_TOKEN, "Add custom token - warning") { + object PotentialScamToken : AddCustomTokenWarning() + object TokenAlreadyAdded : AddCustomTokenWarning() } \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/features/addCustomToken/FormFields.kt b/domain/src/main/java/com/tangem/domain/features/addCustomToken/FormFields.kt index bc03838408..b6084347d2 100644 --- a/domain/src/main/java/com/tangem/domain/features/addCustomToken/FormFields.kt +++ b/domain/src/main/java/com/tangem/domain/features/addCustomToken/FormFields.kt @@ -2,6 +2,7 @@ package com.tangem.domain.features.addCustomToken import com.tangem.blockchain.common.Blockchain import com.tangem.domain.common.form.BaseDataField +import com.tangem.domain.common.form.Field import com.tangem.domain.common.form.FieldId /** @@ -16,21 +17,16 @@ enum class CustomTokenFieldId : FieldId { DerivationPath, } +data class TokenField( + override val id: FieldId, +) : BaseDataField(id, Field.Data("")) + data class TokenNetworkField( override val id: FieldId, val itemList: List, - override val isEnabled: Boolean = true, - override val isVisible: Boolean = true, -) : BaseDataField(id, Blockchain.Unknown) - -data class TokenField( - override val id: FieldId, - override val isEnabled: Boolean = true, - override val isVisible: Boolean = true, -) : BaseDataField(id, "") +) : BaseDataField(id, Field.Data(Blockchain.Unknown)) data class TokenDerivationPathField( override val id: FieldId, - override val isEnabled: Boolean = true, - override val isVisible: Boolean = true, -) : BaseDataField(id, "") + val itemList: List, +) : BaseDataField(id, Field.Data(Blockchain.Unknown)) \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenAction.kt b/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenAction.kt index 7e5e9e6dc6..d4eda81f69 100644 --- a/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenAction.kt +++ b/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenAction.kt @@ -1,34 +1,33 @@ package com.tangem.domain.features.addCustomToken.redux import com.tangem.blockchain.common.Blockchain +import com.tangem.domain.common.form.Field import com.tangem.domain.common.form.FieldId import com.tangem.domain.features.addCustomToken.AddCustomTokenError import com.tangem.domain.features.addCustomToken.AddCustomTokenWarning import com.tangem.domain.features.addCustomToken.CustomTokenFieldId -import com.tangem.network.api.tangemTech.CoinsCheckAddressResponse +import com.tangem.network.api.tangemTech.Coins import org.rekotlin.Action /** [REDACTED_AUTHOR] */ sealed class AddCustomTokenAction : Action { - // initializing actions - data class SetTangemTechAuthHeader(val cardPublicKeyHex: String) : AddCustomTokenAction() - - // from user, ui - object OnBackPressed : AddCustomTokenAction() - data class OnTokenContractAddressChanged(val value: String) : AddCustomTokenAction() - data class OnTokenNetworkChanged(val value: Blockchain) : AddCustomTokenAction() - data class OnTokenDerivationPathChanged(val value: String) : AddCustomTokenAction() - data class OnTokenFieldChanged(val id: FieldId, val value: String) : AddCustomTokenAction() + object OnCreate : AddCustomTokenAction() + object OnDestroy : AddCustomTokenAction() + data class OnTokenFieldChanged(val id: FieldId, val value: Field.Data) : AddCustomTokenAction() + data class OnTokenContractAddressChanged(val value: Field.Data) : AddCustomTokenAction() + data class OnTokenNetworkChanged(val value: Field.Data) : AddCustomTokenAction() + data class OnTokenDerivationPathChanged(val value: Field.Data) : AddCustomTokenAction() + data class OnTokenDecimalsChanged(val value: Field.Data) : AddCustomTokenAction() + data class OnCustomTokenSelected(val any: Any = Unit) : AddCustomTokenAction() - // from redux - object UpdateForm : AddCustomTokenAction() + data class UpdateForm(val state: AddCustomTokenState) : AddCustomTokenAction() data class FillTokenFields( - val token: CoinsCheckAddressResponse.Token, - val contract: CoinsCheckAddressResponse.Token.Contract, + val token: Coins.CheckAddressResponse.Token, + val contract: Coins.CheckAddressResponse.Token.Contract, ) : AddCustomTokenAction() sealed class Error : AddCustomTokenAction() { @@ -37,7 +36,14 @@ sealed class AddCustomTokenAction : Action { } sealed class Warning : AddCustomTokenAction() { - data class Add(val warning: AddCustomTokenWarning) : Warning() - data class Remove(val warning: AddCustomTokenWarning) : Warning() + data class Add(val warnings: Set) : Warning() + data class Remove(val warnings: Set) : Warning() + data class Replace(val remove: Set, val add: Set) : Warning() + } + + // To change the screenState + sealed class Screen : AddCustomTokenAction() { + data class UpdateTokenFields(val pairs: List>) : Screen() + data class UpdateAddButton(val addButton: ViewStates.AddButton) : Screen() } } \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenHub.kt b/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenHub.kt index ea05c79fc7..62eef658b1 100644 --- a/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenHub.kt +++ b/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenHub.kt @@ -2,95 +2,184 @@ package com.tangem.domain.features.addCustomToken.redux import android.webkit.ValueCallback import com.tangem.blockchain.common.Blockchain +import com.tangem.common.card.Card +import com.tangem.domain.DomainDialog import com.tangem.domain.common.form.* import com.tangem.domain.features.addCustomToken.* import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.* import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.* -import com.tangem.domain.store.BaseStoreHub -import com.tangem.domain.store.DomainState -import com.tangem.domain.store.dispatchOnMain +import com.tangem.domain.redux.BaseStoreHub +import com.tangem.domain.redux.DomainState +import com.tangem.domain.redux.dispatchOnMain +import com.tangem.domain.redux.domainStore +import com.tangem.domain.redux.global.DomainGlobalAction +import com.tangem.network.api.tangemTech.Coins import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import org.rekotlin.Action -import org.rekotlin.DispatchFunction /** [REDACTED_AUTHOR] */ -internal object AddCustomTokenHub : BaseStoreHub("AddCustomTokenHub") { +internal class AddCustomTokenHub : BaseStoreHub("AddCustomTokenHub") { - override val initialState: AddCustomTokensState = AddCustomTokensState() + override fun getHubState(storeState: DomainState): AddCustomTokenState { + return storeState.addCustomTokensState + } - override fun handle(state: () -> DomainState?, action: Action, dispatch: DispatchFunction) = when (action) { - is OnBackPressed -> hubScope.cancel() - else -> super.handle(state, action, dispatch) + override fun updateStoreState(storeState: DomainState, newHubState: AddCustomTokenState): DomainState { + return storeState.copy(addCustomTokensState = newHubState) } override suspend fun handleAction( - state: DomainState, action: Action, - dispatch: DispatchFunction, + storeState: DomainState, cancel: ValueCallback ) { if (action !is AddCustomTokenAction) return - val state = state.addCustomTokensState +// val card = storeState.globalState.scanResponse?.card +// ?: throw IllegalStateException("ScanResponse must be set before showing the AddCustomToken screen") + val hubState = storeState.addCustomTokensState when (action) { + is OnCreate -> { +// hubState.addCustomTokenManager.attachAuthKey(card.cardPublicKey.toHexString()) + } + is OnDestroy -> hubScope.cancel() is OnTokenContractAddressChanged -> { val contractAddress = action.value - val validator: TokenContractAddressValidator = getValidator(ContractAddress, state) - val error = validator.validate(contractAddress) + val validator: TokenContractAddressValidator = getValidator(ContractAddress, hubState) + val error = validator.validate(contractAddress.value) addOrRemoveError(ContractAddress, error) - if (error != null) return - - val manager = state.addCustomTokenManager - val selectedNetwork: Blockchain? = getField(Network, state).value.let { - if (it == Blockchain.Unknown) null else it + if (error != null || contractAddress.value.isEmpty()) { + dispatchOnMain(actionsUnlockTokenFields()) + return } - val foundTokens = manager.findContractAddress(contractAddress, selectedNetwork?.id) + + val foundTokens = requestInfoAboutContractAddress(contractAddress.value, hubState) + val warningsToAdd = mutableSetOf() + val warningsToRemove = mutableSetOf() when { - foundTokens.isEmpty() -> {} - foundTokens.size == 1 -> { - // fill and disable other fields by token info - val token = foundTokens[0] - dispatchOnMain(FillTokenFields(token, token.contracts[0])) + foundTokens.isEmpty() -> { + warningsToAdd.add(AddCustomTokenWarning.PotentialScamToken) } else -> { - // show tokens list for selection - + val token = foundTokens[0] + checkToken(null, token, warningsToAdd, warningsToRemove) } - + } + if (warningsToAdd.isNotEmpty() || warningsToRemove.isNotEmpty()) { + dispatchOnMain(Warning.Replace(warningsToRemove.toSet(), warningsToAdd.toSet())) } } is OnTokenNetworkChanged -> { - val validator: TokenNetworkValidator = getValidator(Network, state) - addOrRemoveError(Network, validator.validate(action.value)) - } - is OnTokenFieldChanged -> { - val validator: StringIsNotEmptyValidator = getValidator(action.id, state) - addOrRemoveError(action.id as CustomTokenFieldId, validator.validate(action.value)) + val validator: TokenNetworkValidator = getValidator(Network, hubState) + addOrRemoveError(Network, validator.validate(action.value.value)) } is OnTokenDerivationPathChanged -> { - val validator: DerivationPathValidator = getValidator(DerivationPath, state) - addOrRemoveError(DerivationPath, validator.validate(action.value)) +// val validator: TokenDerivationPathValidator = getValidator(DerivationPath, hubState) +// addOrRemoveError(DerivationPath, validator.validate(action.value.value)) + } + is OnTokenDecimalsChanged -> { + val validator: TokenDecimalsValidator = getValidator(Decimals, hubState) + addOrRemoveError(Decimals, validator.validate(action.value.value)) + } + is OnTokenFieldChanged -> { + val validator: StringIsNotEmptyValidator = getValidator(action.id, hubState) + addOrRemoveError(action.id as CustomTokenFieldId, validator.validate(action.value.value)) + } + is OnCustomTokenSelected -> { +// dispatchOnMain() } is FillTokenFields -> { - val networkField = getField(Network, state) - val nameField = getField(Name, state) - val symbolField = getField(Symbol, state) - val decimalsField = getField(Decimals, state) + val networkField = getField(Network, hubState) + val nameField = getField(Name, hubState) + val symbolField = getField(Symbol, hubState) + val decimalsField = getField(Decimals, hubState) val token = action.token val contract = action.contract - networkField.value = Blockchain.fromId(contract.networkId) - nameField.value = token.name - symbolField.value = token.symbol - decimalsField.value = contract.decimalCount.toString() + val blockchain = Blockchain.fromNetworkId(contract.networkId) + networkField.data = Field.Data(blockchain, false) + nameField.data = Field.Data(token.name, false) + symbolField.data = Field.Data(token.symbol, false) + decimalsField.data = Field.Data(contract.decimalCount.toString(), false) - dispatchOnMain(UpdateForm) + dispatchOnMain(UpdateForm(hubState)) + } + else -> {} + } + } + + private suspend fun requestInfoAboutContractAddress( + contractAddress: String, + hubState: AddCustomTokenState + ): List { + dispatchOnMain(Screen.UpdateTokenFields(listOf(ContractAddress to ViewStates.TokenField(isLoading = true)))) + val tokenManager = hubState.addCustomTokenManager + val field = getField(Network, hubState) + val selectedNetworkId: String? = field.data.value.let { + if (it == Blockchain.Unknown) null else it + }?.toNetworkId() + val foundTokens = tokenManager.checkAddress(contractAddress, selectedNetworkId) + dispatchOnMain(Screen.UpdateTokenFields(listOf(ContractAddress to ViewStates.TokenField(isLoading = false)))) + return foundTokens + } + + private suspend fun checkToken( + card: Card?, + token: Coins.CheckAddressResponse.Token, + warningsToAdd: MutableSet, + warningsToRemove: MutableSet, + ) { + val contracts = token.contracts + when { + contracts.isEmpty() -> { + } + contracts.size == 1 -> { + val contract = contracts[0] + val isPersistIntoTheAppAddedTokenList = isPersistIntoTheAppAddedTokenList(token, contract) + + if (isPersistIntoTheAppAddedTokenList) { + warningsToAdd.add(AddCustomTokenWarning.TokenAlreadyAdded) + dispatchOnMain(Screen.UpdateAddButton(ViewStates.AddButton(false))) + dispatchOnMain(actionsLockTokenFields()) + } else { + dispatchOnMain(Screen.UpdateAddButton(ViewStates.AddButton(true))) +// val isStandardDerivation = card.derivationType == DerivationType.Standard + val isStandardDerivation = true + val isStandardToken = token.active && isStandardDerivation + if (isStandardToken) { + dispatchOnMain(FillTokenFields(token, contract)) + dispatchOnMain(actionsLockTokenFields()) + } else { + warningsToAdd.add(AddCustomTokenWarning.PotentialScamToken) + dispatchOnMain(actionsUnlockTokenFields()) + } + } + } + else -> { + val dialog = DomainDialog.SelectTokenDialog( + items = contracts, + itemNameConverter = { it.address }, + onSelect = { selectedContract -> + hubScope.launch { + // find how to connect to the upper coroutineContext and dispatch through them + dispatchOnMain(FillTokenFields(token, selectedContract)) + dispatchOnMain(FillTokenFields(token, selectedContract)) + } + }, + ) + dispatchOnMain(DomainGlobalAction.ShowDialog(dialog)) } } } + private fun isPersistIntoTheAppAddedTokenList( + token: Coins.CheckAddressResponse.Token, + contract: Coins.CheckAddressResponse.Token.Contract + ): Boolean = false + private suspend fun addOrRemoveError(id: CustomTokenFieldId, error: AddCustomTokenError?) { if (error == null) { dispatchOnMain(Error.Remove(id)) @@ -99,38 +188,62 @@ internal object AddCustomTokenHub : BaseStoreHub("AddCusto } } - private inline fun getField(id: FieldId, state: AddCustomTokensState): T { + private fun actionsLockTokenFields(): Action { + val state = domainStore.state.addCustomTokensState + return Screen.UpdateTokenFields(listOf( + Network to state.screenState.network.copy(isEnabled = false), + Name to state.screenState.name.copy(isEnabled = false), + Symbol to state.screenState.symbol.copy(isEnabled = false), + Decimals to state.screenState.decimals.copy(isEnabled = false), + )) + } + + private fun actionsUnlockTokenFields(): Action { + val state = domainStore.state.addCustomTokensState + return Screen.UpdateTokenFields(listOf( + Network to state.screenState.network.copy(isEnabled = true), + Name to state.screenState.name.copy(isEnabled = true), + Symbol to state.screenState.symbol.copy(isEnabled = true), + Decimals to state.screenState.decimals.copy(isEnabled = true), + )) + } + + private inline fun getField(id: FieldId, state: AddCustomTokenState): T { return state.form.getField(id) as T } - private inline fun getValidator(id: FieldId, state: AddCustomTokensState): T { + private inline fun getValidator(id: FieldId, state: AddCustomTokenState): T { return state.getValidator(id) as T } - override fun reduceAction(action: Action, state: AddCustomTokensState): AddCustomTokensState { + override fun reduceAction(action: Action, state: AddCustomTokenState): AddCustomTokenState { return when (action) { - is SetTangemTechAuthHeader -> { - state.apply { addCustomTokenManager.attachAuthKey(action.cardPublicKeyHex) } - } - is UpdateForm -> updateFormState(state) - is OnTokenNetworkChanged -> { - val field: TokenNetworkField = getField(Network, state) - field.value = action.value - updateFormState(state) + is UpdateForm -> { + updateFormState(action.state) } is OnTokenContractAddressChanged -> { val field: TokenField = getField(ContractAddress, state) - field.value = action.value + field.data = action.value + updateFormState(state) + } + is OnTokenNetworkChanged -> { + val field: TokenNetworkField = getField(Network, state) + field.data = action.value updateFormState(state) } is OnTokenDerivationPathChanged -> { - val field: TokenDerivationPathField = getField(Network, state) - field.value = action.value + val field: TokenDerivationPathField = getField(DerivationPath, state) + field.data = action.value + updateFormState(state) + } + is OnTokenDecimalsChanged -> { + val field: TokenField = getField(Decimals, state) + field.data = action.value updateFormState(state) } is OnTokenFieldChanged -> { val field: TokenField = getField(action.id, state) - field.value = action.value + field.data = action.value updateFormState(state) } is Error.Add -> { @@ -142,18 +255,143 @@ internal object AddCustomTokenHub : BaseStoreHub("AddCusto state.copy(formErrors = newMap) } is Warning.Add -> { - val newList = state.warnings.toMutableList().apply { add(action.warning) } - state.copy(warnings = newList) + val newList = state.warnings.toMutableSet().apply { addAll(action.warnings) } + state.copy(warnings = newList.toSet()) } is Warning.Remove -> { - val newList = state.warnings.toMutableList().apply { remove(action.warning) } - state.copy(warnings = newList) + val newList = state.warnings.toMutableSet().apply { removeAll(action.warnings) } + state.copy(warnings = newList.toSet()) + } + is Warning.Replace -> { + val newList = state.warnings.toMutableSet().apply { + removeAll(action.remove) + addAll(action.add) + } + state.copy(warnings = newList.toSet()) + } + is Screen.UpdateTokenFields -> { + var newScreenState = state.screenState + action.pairs.forEach { + newScreenState = when (it.first) { + ContractAddress -> { + if (state.screenState.contractAddressField == it.second) { + newScreenState + } else { + newScreenState.copy(contractAddressField = it.second) + } + } + Network -> { + if (state.screenState.network == it.second) { + newScreenState + } else { + newScreenState.copy(network = it.second) + } + } + Name -> { + if (state.screenState.name == it.second) { + newScreenState + } else { + newScreenState.copy(name = it.second) + } + } + Symbol -> { + if (state.screenState.symbol == it.second) { + newScreenState + } else { + newScreenState.copy(symbol = it.second) + } + } + Decimals -> { + if (state.screenState.decimals == it.second) { + newScreenState + } else { + newScreenState.copy(decimals = it.second) + } + } + else -> newScreenState + } + } + if (state.screenState == newScreenState) { + state + } else { + state.copy(screenState = newScreenState) + } + } + is Screen.UpdateAddButton -> { + val newScreenState = if (state.screenState.addButton == action.addButton) { + state.screenState + } else { + state.screenState.copy(addButton = action.addButton) + } + if (newScreenState == state.screenState) { + state + } else { + state.copy(screenState = newScreenState) + } } else -> state } } - private fun updateFormState(state: AddCustomTokensState): AddCustomTokensState { + private fun updateFormState(state: AddCustomTokenState): AddCustomTokenState { return state.copy(form = Form(state.form.fieldList)) } +} + +//TODO: refactoring: replace by Blockchain.Companion.fromNetworkId +fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain { + return when (networkId) { + "avalanche" -> Blockchain.Avalanche + "binancecoin" -> Blockchain.Binance + "binance-smart-chain" -> Blockchain.BSC + "ethereum" -> Blockchain.Ethereum + "polygon-pos" -> Blockchain.Polygon + "solana" -> Blockchain.Solana + "fantom" -> Blockchain.Fantom + "bitcoin" -> Blockchain.Bitcoin + "bitcoin-cash" -> Blockchain.BitcoinCash + "cardano" -> Blockchain.CardanoShelley + "dogecoin" -> Blockchain.Dogecoin + "ducatus" -> Blockchain.Ducatus + "litecoin" -> Blockchain.Litecoin + "rsk" -> Blockchain.RSK + "stellar" -> Blockchain.Stellar + "tezos" -> Blockchain.Tezos + "ripple" -> Blockchain.XRP + else -> Blockchain.Unknown + } +} + +fun Blockchain.toNetworkId(): String? { + return when (this) { + Blockchain.Unknown -> null + Blockchain.Avalanche -> "avalanche" + Blockchain.AvalancheTestnet -> "avalanche" + Blockchain.Binance -> "binancecoin" + Blockchain.BinanceTestnet -> "binancecoin" + Blockchain.BSC -> "binance-smart-chain" + Blockchain.BSCTestnet -> "binance-smart-chain" + Blockchain.Bitcoin -> "bitcoin" + Blockchain.BitcoinTestnet -> "bitcoin" + Blockchain.BitcoinCash -> "bitcoin-cash" + Blockchain.BitcoinCashTestnet -> "bitcoin-cash" + Blockchain.Cardano -> "cardano" + Blockchain.CardanoShelley -> "cardano" + Blockchain.Dogecoin -> "dogecoin" + Blockchain.Ducatus -> "ducatus" + Blockchain.Ethereum -> "ethereum" + Blockchain.EthereumTestnet -> "ethereum" + Blockchain.Fantom -> "fantom" + Blockchain.FantomTestnet -> "fantom" + Blockchain.Litecoin -> "litecoin" + Blockchain.Polygon -> "matic-network" + Blockchain.PolygonTestnet -> "matic-networks" + Blockchain.RSK -> "rootstock" + Blockchain.Stellar -> "stellar" + Blockchain.StellarTestnet -> "stellar" + Blockchain.Solana -> "solana" + Blockchain.SolanaTestnet -> "solana" + Blockchain.Tezos -> "tezos" + Blockchain.XRP -> "ripple" + } } \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenState.kt b/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenState.kt new file mode 100644 index 0000000000..d8ac3b84ee --- /dev/null +++ b/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenState.kt @@ -0,0 +1,117 @@ +package com.tangem.domain.features.addCustomToken.redux + +import com.tangem.blockchain.common.Blockchain +import com.tangem.domain.common.form.* +import com.tangem.domain.features.addCustomToken.* +import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.* +import com.tangem.network.api.tangemTech.TangemTechService +import org.rekotlin.StateType + +data class AddCustomTokenState( + val form: Form = Form(createFormFields()), + val formValidators: Map> = createFormValidators(), + val formErrors: Map = emptyMap(), + val warnings: Set = emptySet(), + val screenState: ScreenState = createInitialScreenState(), + val addCustomTokenManager: AddCustomTokenManager = AddCustomTokenManager(TangemTechService()) +) : StateType { + + val completeDataType: CompleteDataType + get() = calculateDataType() + + inline fun visitDataConverter(converter: FieldDataConverter): T { + form.visitDataConverter(converter) + return converter.getConvertedData() + } + + fun getValidator(id: FieldId): CustomTokenValidator<*> = formValidators[id]!! + + fun hasError(id: FieldId): Boolean = formErrors[id] != null + + fun getError(id: FieldId): AddCustomTokenError? { + return formErrors[id] + } + + private fun calculateDataType(): CompleteDataType { + val idsToCheck = listOf(ContractAddress, Name, Symbol, Decimals) + val fieldsToCheck = form.fieldList.filter { idsToCheck.contains(it.id) } + + val isEmptyValidator = StringIsEmptyValidator() + fieldsToCheck.map { data -> data.toString() }.forEach { + // if one of the fields has error -> then it + val error = isEmptyValidator.validate(it) + if (error != null) return CompleteDataType.Token + } + + return CompleteDataType.Blockchain + } + + companion object { + fun convertBlockchainName(blockchain: Blockchain, unknown: String): String = when (blockchain) { + Blockchain.Unknown -> unknown + Blockchain.Cardano -> "Cardano" + Blockchain.CardanoShelley -> "Cardano Shelley" + else -> blockchain.fullName + } + + fun convertDerivationPathName(blockchain: Blockchain, unknown: String): String = when (blockchain) { + Blockchain.Unknown -> unknown + Blockchain.BSC -> "BNB Smart Chain" + Blockchain.Fantom -> "Fantom Opera" + else -> blockchain.fullName + } + + private fun createFormFields(): List> { + return listOf( + TokenField(ContractAddress), + TokenNetworkField(Network, getSupportedNetworks()), + TokenField(Name), + TokenField(Symbol), + TokenField(Decimals), + TokenDerivationPathField(DerivationPath, getSupportedDerivations()), + ) + } + + private fun createFormValidators(): Map> { + return mapOf( + ContractAddress to TokenContractAddressValidator(), + Network to TokenNetworkValidator(), + Name to StringIsNotEmptyValidator(), + Symbol to StringIsNotEmptyValidator(), + Decimals to TokenDecimalsValidator(), +// DerivationPath to TokenDerivationPathValidator(), + ) + } + + private fun getSupportedNetworks(): List { + return listOf( + Blockchain.Ethereum, + Blockchain.BSC, + Blockchain.Binance, + Blockchain.Polygon, + Blockchain.Avalanche, +// Blockchain.Solana, // not supported until tokens added to the Blockchain SDK + Blockchain.Fantom, + ) + } + + private fun getSupportedDerivations(): List { + val evmBlockchains = Blockchain.values().filter { + !it.isTestnet() && it.getChainId() != null + } + return evmBlockchains + } + + private fun createInitialScreenState(): ScreenState { + return ScreenState( + contractAddressField = ViewStates.TokenField(), + network = ViewStates.TokenField(), + name = ViewStates.TokenField(), + symbol = ViewStates.TokenField(), + decimals = ViewStates.TokenField(), + derivationPath = ViewStates.TokenField(), + addButton = ViewStates.AddButton() + ) + } + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokensState.kt b/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokensState.kt deleted file mode 100644 index 45976a52d0..0000000000 --- a/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokensState.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.tangem.domain.features.addCustomToken.redux - -import com.tangem.blockchain.common.Blockchain -import com.tangem.domain.common.form.* -import com.tangem.domain.features.addCustomToken.* -import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.* -import com.tangem.network.api.tangemTech.TangemTechService -import org.rekotlin.StateType - -data class AddCustomTokensState( - val form: Form = Form(createFormFields()), - val formValidators: Map> = createFormValidators(), - val formErrors: Map = emptyMap(), - val warnings: List = emptyList(), - val addCustomTokenManager: AddCustomTokenManager = AddCustomTokenManager(TangemTechService()) -) : StateType { - - val completeDataType: CompleteDataType - get() = calculateDataType() - - fun getData( - converter: FieldDataConverter = CompleteData.createDataConverter(completeDataType) - ): CompleteData { - form.getData(converter) - return converter.getConvertedData() - } - - fun getLockedFieldsForKnownToken(): List { - return listOf( - Name, Symbol, Decimals - ) - } - - fun getValidator(id: FieldId): CustomTokenValidator<*> = formValidators[id]!! - - fun hasError(id: FieldId): Boolean = formErrors[id] != null - - fun getError(id: FieldId): AddCustomTokenError? { - return formErrors[id] - } - - private fun calculateDataType(): CompleteDataType { - val idsToCheck = listOf(ContractAddress, Name, Symbol, Decimals) - val fieldsToCheck = form.fieldList.filter { idsToCheck.contains(it.id) } - - val isEmptyValidator = StringIsEmptyValidator() - fieldsToCheck.map { data -> data.toString() }.forEach { - // if one of the fields has error -> then it - val error = isEmptyValidator.validate(it) - if (error != null) return CompleteDataType.Token - } - - return CompleteDataType.Blockchain - } - - - companion object Utils { - private fun createFormFields(): List> { - return listOf( - TokenField(ContractAddress), - TokenNetworkField(Network, getSupportedBlockchains()), - TokenField(Name), - TokenField(Symbol), - TokenField(Decimals), - TokenDerivationPathField(DerivationPath), - ) - } - - private fun createFormValidators(): Map> { - return mapOf( - ContractAddress to TokenContractAddressValidator(), - Network to TokenNetworkValidator(), - Name to StringIsNotEmptyValidator(), - Symbol to StringIsNotEmptyValidator(), - Decimals to StringIsNotEmptyValidator(), - DerivationPath to DerivationPathValidator(), - ) - } - - private fun getSupportedBlockchains(): List { - return Blockchain.values().filter { !it.isTestnet() }.toList() - } - } -} - -enum class CompleteDataType { - Blockchain, Token -} - -sealed class CompleteData() { - - companion object { - fun createDataConverter(completeDataType: CompleteDataType): FieldDataConverter = - when (completeDataType) { - CompleteDataType.Blockchain -> CustomBlockchain.Converter() - CompleteDataType.Token -> CustomToken.Converter() - } - } - - class CustomBlockchain( - val selectedNetwork: Blockchain, - val derivationPath: String? - ) : CompleteData() { - - class Converter : BaseFieldDataConverter() { - override fun getConvertedData(): CustomBlockchain = CustomBlockchain( - collectedData[Network] as Blockchain, - collectedData[DerivationPath] as? String, - ) - - override fun getIdToCollect(): List = listOf(Network, DerivationPath) - } - } - - class CustomToken( - val contractAddress: String, - val selectedNetwork: Blockchain, - val name: String, - val tokenSymbol: String, - val decimals: Int, - val derivationPath: String?, - ) : CompleteData() { - - class Converter : BaseFieldDataConverter() { - override fun getConvertedData(): CustomToken = CustomToken( - collectedData[ContractAddress] as String, - collectedData[Network] as Blockchain, - collectedData[Name] as String, - collectedData[Symbol] as String, - collectedData[Decimals] as Int, - collectedData[DerivationPath] as? String, - ) - - override fun getIdToCollect(): List = CustomTokenFieldId.values().toList() - } - } -} \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/Models.kt b/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/Models.kt new file mode 100644 index 0000000000..2e1d3bf88e --- /dev/null +++ b/domain/src/main/java/com/tangem/domain/features/addCustomToken/redux/Models.kt @@ -0,0 +1,31 @@ +package com.tangem.domain.features.addCustomToken.redux + +/** +[REDACTED_AUTHOR] + */ +enum class CompleteDataType { + Blockchain, Token +} + +// describes state the screen, except the form fields +data class ScreenState( + val contractAddressField: ViewStates.TokenField, + val network: ViewStates.TokenField, + val name: ViewStates.TokenField, + val symbol: ViewStates.TokenField, + val decimals: ViewStates.TokenField, + val derivationPath: ViewStates.TokenField, + val addButton: ViewStates.AddButton +) + +sealed class ViewStates { + data class TokenField( + val isLoading: Boolean = false, + val isEnabled: Boolean = true, + val isVisible: Boolean = true, + ) : ViewStates() + + data class AddButton( + val isEnabled: Boolean = true + ) : ViewStates() +} \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/redux/DomainState.kt b/domain/src/main/java/com/tangem/domain/redux/DomainState.kt new file mode 100644 index 0000000000..49cc235412 --- /dev/null +++ b/domain/src/main/java/com/tangem/domain/redux/DomainState.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.redux + +import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState +import com.tangem.domain.redux.global.DomainGlobalState +import org.rekotlin.StateType + +/** +[REDACTED_AUTHOR] + */ +data class DomainState( + val globalState: DomainGlobalState = DomainGlobalState(), + val addCustomTokensState: AddCustomTokenState = AddCustomTokenState(), +) : StateType \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/redux/DomainStore.kt b/domain/src/main/java/com/tangem/domain/redux/DomainStore.kt new file mode 100644 index 0000000000..78bc7cf624 --- /dev/null +++ b/domain/src/main/java/com/tangem/domain/redux/DomainStore.kt @@ -0,0 +1,33 @@ +package com.tangem.domain.redux + +import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenHub +import com.tangem.domain.redux.global.DomainGlobalHub +import com.tangem.domain.redux.state.observeReducedStates +import org.rekotlin.Store + +/** +[REDACTED_AUTHOR] + */ +private class DomainStore // for simple search + +private val RE_STORE_HUBS: List> = listOf( + DomainGlobalHub(), + AddCustomTokenHub(), +) + +val domainStore = Store( + state = DomainState(), + middleware = RE_STORE_HUBS.map { it.getMiddleware() }, + reducer = { action, state -> + requireNotNull(state) + + // we can examine the store state after each change by reducer + val reducedSates = RE_STORE_HUBS.mapNotNull { + val reducedState = it.reduce(action, state) + if (reducedState == state) null else Pair(action, reducedState) + } + observeReducedStates(reducedSates) + + if (reducedSates.isEmpty()) state else reducedSates.last().second + } +) diff --git a/domain/src/main/java/com/tangem/domain/redux/ReStoreHub.kt b/domain/src/main/java/com/tangem/domain/redux/ReStoreHub.kt new file mode 100644 index 0000000000..c4c98fc143 --- /dev/null +++ b/domain/src/main/java/com/tangem/domain/redux/ReStoreHub.kt @@ -0,0 +1,107 @@ +package com.tangem.domain.redux + +import android.webkit.ValueCallback +import com.tangem.domain.common.FeatureCoroutineExceptionHandler +import com.tangem.domain.common.extensions.withIOContext +import com.tangem.domain.common.extensions.withMainContext +import kotlinx.coroutines.* +import org.rekotlin.Action +import org.rekotlin.DispatchFunction +import org.rekotlin.Middleware +import java.util.concurrent.Executors + +/** +[REDACTED_AUTHOR] + * ReStoreHub's should not store the or the , because this can lead to destabilization of + * a state behavior. + */ +// all ReStoreHub's must be marked as internal +internal interface ReStoreHub : HubMiddleware, HubReducer + +internal interface HubMiddleware { + fun getMiddleware(): Middleware +} + +internal interface HubReducer { + fun reduce(action: Action, storeState: StoreState): StoreState +} + +/** + * ReStoreHub is the entry point for actions. It processes it through middleware and reducer. + * Actions handled by ReStoreHub go into coroutine scope, which can be canceled while the action is being processed. + * All action went from the middleware must be dispatched through ReStoreHub.dispatchOnMain(Actions) to prevent + * concurrent modification in the Store + * Only the changed hub State will change its state in the DomainState + * @param name - name of the Hub + * @param dispatcher - main coroutine dispatcher for actions + */ +internal abstract class BaseStoreHub( + private val name: String, + private val dispatcher: CoroutineDispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher() +) : ReStoreHub { + + val hubScope = CoroutineScope( + Job() + dispatcher + CoroutineName(name) + FeatureCoroutineExceptionHandler.create(name) + ) + + private val actionsAndJobs = mutableMapOf() + + override fun getMiddleware(): Middleware { + return { dispatch, state -> + { next -> + { action -> + handle(state, action, dispatch) + next(action) + } + } + } + } + + /** + * Launches new coroutine and stores the action with it's coroutine job. (Coroutine can be cancelled + * through invoking the cancelActionJob() function inside a middleware). + * Removes the action when job is completed. + */ + protected open fun handle(storeStateHolder: () -> DomainState?, action: Action, dispatch: DispatchFunction) { + val storeState = storeStateHolder() + ?: throw UnsupportedOperationException("StoreState for the $name can't be NULL") + + hubScope.launch { + actionsAndJobs[action] = this.coroutineContext.job + actionsAndJobs[action]?.invokeOnCompletion { actionsAndJobs.remove(action) } + + handleAction(action, storeState) { + actionsAndJobs.remove(it)?.cancel() + } + } + } + + /** + * Reduce the action and check if - if the action hasn't updated the hubState, then it doesn't need to update + * storeState + */ + override fun reduce(action: Action, storeState: DomainState): DomainState { + val oldState = getHubState(storeState) + val newState = reduceAction(action, oldState) + return if (oldState === newState) { + storeState + } else { + updateStoreState(storeState, newState) + } + } + + protected abstract suspend fun handleAction(action: Action, storeState: DomainState, cancel: ValueCallback) + protected abstract fun reduceAction(action: Action, state: State): State + + protected abstract fun getHubState(storeState: DomainState): State + protected abstract fun updateStoreState(storeState: DomainState, newHubState: State): DomainState + +} + +internal suspend inline fun ReStoreHub<*, *>.dispatchOnMain(vararg actions: Action) { + withMainContext { actions.forEach { domainStore.dispatch(it) } } +} + +internal suspend inline fun ReStoreHub<*, *>.dispatchOnIO(vararg actions: Action) { + withIOContext { actions.forEach { domainStore.dispatch(it) } } +} diff --git a/domain/src/main/java/com/tangem/domain/features/global/redux/DomainGlobalAction.kt b/domain/src/main/java/com/tangem/domain/redux/global/DomainGlobalAction.kt similarity index 62% rename from domain/src/main/java/com/tangem/domain/features/global/redux/DomainGlobalAction.kt rename to domain/src/main/java/com/tangem/domain/redux/global/DomainGlobalAction.kt index ca26927233..1ce8b77035 100644 --- a/domain/src/main/java/com/tangem/domain/features/global/redux/DomainGlobalAction.kt +++ b/domain/src/main/java/com/tangem/domain/redux/global/DomainGlobalAction.kt @@ -1,5 +1,6 @@ -package com.tangem.domain.features.global.redux +package com.tangem.domain.redux.global +import com.tangem.domain.DomainStateDialog import com.tangem.domain.common.ScanResponse import org.rekotlin.Action @@ -9,4 +10,5 @@ import org.rekotlin.Action //TODO: refactoring: is alias for the GlobalAction sealed class DomainGlobalAction : Action { data class SetScanResponse(val scanResponse: ScanResponse?) : DomainGlobalAction() + data class ShowDialog(val stateDialog: DomainStateDialog?) : DomainGlobalAction() } \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/features/global/redux/DomainGlobalHub.kt b/domain/src/main/java/com/tangem/domain/redux/global/DomainGlobalHub.kt similarity index 63% rename from domain/src/main/java/com/tangem/domain/features/global/redux/DomainGlobalHub.kt rename to domain/src/main/java/com/tangem/domain/redux/global/DomainGlobalHub.kt index 40ea093ae5..494dc41e7e 100644 --- a/domain/src/main/java/com/tangem/domain/features/global/redux/DomainGlobalHub.kt +++ b/domain/src/main/java/com/tangem/domain/redux/global/DomainGlobalHub.kt @@ -1,10 +1,9 @@ -package com.tangem.domain.features.global.redux +package com.tangem.domain.redux.global import android.webkit.ValueCallback -import com.tangem.domain.restore.BaseStoreHub -import com.tangem.domain.restore.DomainState +import com.tangem.domain.redux.BaseStoreHub +import com.tangem.domain.redux.DomainState import org.rekotlin.Action -import org.rekotlin.DispatchFunction /** [REDACTED_AUTHOR] @@ -16,19 +15,18 @@ internal class DomainGlobalHub : BaseStoreHub("DomainGlobalHu return storeState.globalState } - override fun updateStoreState(storeState: DomainState, newState: DomainGlobalState): DomainState { - return storeState.copy(globalState = newState) + override fun updateStoreState(storeState: DomainState, newHubState: DomainGlobalState): DomainState { + return storeState.copy(globalState = newHubState) } override suspend fun handleAction( - state: DomainState, action: Action, - dispatch: DispatchFunction, + storeState: DomainState, cancel: ValueCallback ) { if (action !is DomainGlobalAction) return - val state = state.globalState + val state = storeState.globalState when (action) { } @@ -38,6 +36,9 @@ internal class DomainGlobalHub : BaseStoreHub("DomainGlobalHu is DomainGlobalAction.SetScanResponse -> { state.copy(scanResponse = action.scanResponse) } + is DomainGlobalAction.ShowDialog -> { + state.copy(dialog = action.stateDialog) + } else -> state } } \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/features/global/redux/DomainGlobalState.kt b/domain/src/main/java/com/tangem/domain/redux/global/DomainGlobalState.kt similarity index 61% rename from domain/src/main/java/com/tangem/domain/features/global/redux/DomainGlobalState.kt rename to domain/src/main/java/com/tangem/domain/redux/global/DomainGlobalState.kt index 6335cc79b3..b629778ccc 100644 --- a/domain/src/main/java/com/tangem/domain/features/global/redux/DomainGlobalState.kt +++ b/domain/src/main/java/com/tangem/domain/redux/global/DomainGlobalState.kt @@ -1,5 +1,6 @@ -package com.tangem.domain.features.global.redux +package com.tangem.domain.redux.global +import com.tangem.domain.DomainStateDialog import com.tangem.domain.common.ScanResponse /** @@ -8,4 +9,5 @@ import com.tangem.domain.common.ScanResponse //TODO: refactoring: is alias for the GlobalState data class DomainGlobalState( val scanResponse: ScanResponse? = null, + val dialog: DomainStateDialog? = null, ) diff --git a/domain/src/main/java/com/tangem/domain/redux/state/StateObservables.kt b/domain/src/main/java/com/tangem/domain/redux/state/StateObservables.kt new file mode 100644 index 0000000000..233bbaa618 --- /dev/null +++ b/domain/src/main/java/com/tangem/domain/redux/state/StateObservables.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.redux.state + +import com.tangem.domain.features.addCustomToken.AddCustomTokenStatePrinter +import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction +import com.tangem.domain.redux.DomainState +import org.rekotlin.Action +import timber.log.Timber + +/** +[REDACTED_AUTHOR] + * Use it only in debug mode! + */ +internal fun observeReducedStates(reducedSates: List>) { + // we can add any logic to watch for changes of actions, states, etc. + val isDebugMode = true + if (!isDebugMode) return + + logStates(reducedSates) +} + +private fun logStates(reducedSates: List>) { + reducedSates.forEach { + val printer = statePrinters.firstNotNullOfOrNull { entry -> + if (entry.key.isAssignableFrom(it.first::class.java)) entry.value else null + } ?: return@forEach + + val messageToPrint = printer.print(it.first, it.second) ?: return@forEach + + Timber.d(messageToPrint) + } +} + +// TODO: refactoring: mutate to factory +private val statePrinters = mutableMapOf( + AddCustomTokenAction::class.java to AddCustomTokenStatePrinter() +) \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/redux/state/StatePrinter.kt b/domain/src/main/java/com/tangem/domain/redux/state/StatePrinter.kt new file mode 100644 index 0000000000..8f617eea4d --- /dev/null +++ b/domain/src/main/java/com/tangem/domain/redux/state/StatePrinter.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.redux.state + +import com.tangem.domain.redux.DomainState +import org.rekotlin.Action + +/** +[REDACTED_AUTHOR] + */ +interface StatePrinter { + fun print(action: Action, domainState: DomainState): String? + fun getStateObject(domainState: DomainState): S +} \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/store/DomainStore.kt b/domain/src/main/java/com/tangem/domain/store/DomainStore.kt deleted file mode 100644 index 57e8c8401d..0000000000 --- a/domain/src/main/java/com/tangem/domain/store/DomainStore.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.tangem.domain.store - -import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenHub -import com.tangem.domain.features.addCustomToken.redux.AddCustomTokensState -import org.rekotlin.Action -import org.rekotlin.Middleware -import org.rekotlin.StateType -import org.rekotlin.Store - -/** -[REDACTED_AUTHOR] - */ -private class DomainStore // for simple search - -val domainStore = Store( - state = DomainState(), - middleware = domainMiddlewares(), - reducer = { action, state -> domainReduce(action, state) } -) - -data class DomainState( - val addCustomTokensState: AddCustomTokensState = AddCustomTokenHub.initialState -) : StateType - -private fun domainMiddlewares(): List> { - return listOf( - AddCustomTokenHub.middleware - ) -} - -private fun domainReduce(action: Action, state: DomainState?): DomainState { - requireNotNull(state) - - return DomainState( - addCustomTokensState = AddCustomTokenHub.reduceAction(action, state.addCustomTokensState) - ) -} \ No newline at end of file diff --git a/domain/src/main/java/com/tangem/domain/store/StoreHub.kt b/domain/src/main/java/com/tangem/domain/store/StoreHub.kt deleted file mode 100644 index 7322b42e91..0000000000 --- a/domain/src/main/java/com/tangem/domain/store/StoreHub.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.tangem.domain.store - -import android.webkit.ValueCallback -import com.tangem.domain.common.FeatureCoroutineExceptionHandler -import com.tangem.domain.common.extensions.withIOContext -import com.tangem.domain.common.extensions.withMainContext -import kotlinx.coroutines.* -import org.rekotlin.Action -import org.rekotlin.DispatchFunction -import org.rekotlin.Middleware - -/** -[REDACTED_AUTHOR] - */ -interface StoreHub { - val initialState: State - val middleware: Middleware - fun reduceAction(action: Action, state: State): State -} - -/** - * Hub contains the entry points for actions. It processes it through middleware and reducer. - * All action went from the middleware must be dispatched through StoreHub.dispatchOnMain(Actions) - * and StoreHub.dispatchOnIO(Actions) - - * Hub is the provider of an initial state of a State. - * - * @param name - name of the Hub - */ -abstract class BaseStoreHub( - private val name: String, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO -) : StoreHub { - - protected val actionsAndJobs = mutableMapOf() - protected val hubScope = CoroutineScope( - Job() + dispatcher + CoroutineName(name) + FeatureCoroutineExceptionHandler.create(name) - ) - - /** - * Main entry point for the all actions - */ - override val middleware: Middleware = { dispatch, state -> - { next -> - { action -> - handle(state, action, dispatch) - next(action) - } - } - } - - /** - * Launches new coroutine and stores the action with it's coroutine job. (Coroutine can be cancelled - * through invoking the cancelActionJob() function inside a middleware). - * Removes the action when job is completed. - */ - protected open fun handle(state: () -> DomainState?, action: Action, dispatch: DispatchFunction) { - val domainState = state() ?: throw UnsupportedOperationException("State for the $name can't be NULL") - - hubScope.launch { - actionsAndJobs[action] = this.coroutineContext.job - actionsAndJobs[action]?.invokeOnCompletion { actionsAndJobs.remove(action) } - - handleAction( - state = domainState, - action = action, - dispatch = dispatch, - cancel = { actionsAndJobs.remove(it)?.cancel() } - ) - } - } - - protected abstract suspend fun handleAction( - state: DomainState, - action: Action, - dispatch: DispatchFunction, - cancel: ValueCallback, - ) -} - -internal suspend inline fun StoreHub<*, *>.dispatchOnMain(vararg actions: Action) { - withMainContext { actions.forEach { domainStore.dispatch(it) } } -} - -internal suspend inline fun StoreHub<*, *>.dispatchOnIO(vararg actions: Action) { - withIOContext { actions.forEach { domainStore.dispatch(it) } } -} \ No newline at end of file