Updated on 2026-08-14

This commit is contained in:
Tangem 2023-04-19 17:39:44 +03:00
parent 025fc498a3
commit b05e10db56
132 changed files with 231 additions and 189 deletions

View file

@ -0,0 +1,19 @@
package com.tangem.domain
import com.tangem.common.extensions.VoidCallback
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
/**
[REDACTED_AUTHOR]
*/
sealed interface DomainDialog {
data class DialogError(val error: DomainModuleError) : DomainDialog
data class SelectTokenDialog(
val items: List<CoinsResponse.Coin.Network>,
val networkIdConverter: (String) -> String,
val onSelect: (CoinsResponse.Coin.Network) -> Unit,
val onClose: VoidCallback = {},
) : DomainDialog
}

View file

@ -0,0 +1,26 @@
package com.tangem.domain
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState
import com.tangem.domain.redux.state.ActionStateLoggerImpl
/**
[REDACTED_AUTHOR]
*/
object DomainLayer {
internal val actionStateLogger = ActionStateLoggerImpl()
var onInitComplete: ((DomainModuleError?) -> Unit)? = null
fun init() {
initActionStateLogger()
onInitComplete?.invoke(null)
}
private fun initActionStateLogger() {
val factory = actionStateLogger.actionStateConvertersFactory
factory.addConverter(AddCustomTokenAction::class.java, AddCustomTokenState.Converter())
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.domain
import com.tangem.common.module.FbConsumeException
import com.tangem.common.module.ModuleError
import com.tangem.common.module.ModuleErrorCode
import com.tangem.common.module.ModuleMessage
/**
[REDACTED_AUTHOR]
* All DomainError descendants must use their own range of codes, but no more than 999 error codes for each.
*/
sealed interface DomainModuleMessage : ModuleMessage
sealed class DomainModuleError(
subCode: Int,
override val message: String,
override val data: Any?,
) : DomainModuleMessage, ModuleError() {
override val code: Int = ModuleErrorCode.DOMAIN + subCode
companion object {
// base code used for all errors in the module
internal const val ERROR_CODE_ADD_CUSTOM_TOKEN = 100
// const val CODE_ANY_OTHER = 200..299, 300..399 etc
}
}
sealed class AddCustomTokenError(
subCode: Int = 0,
message: String? = null,
data: Any? = null,
) : DomainModuleError(
subCode = ERROR_CODE_ADD_CUSTOM_TOKEN + subCode,
message = message ?: this::class.java.simpleName,
data = data,
) {
object FieldIsEmpty : AddCustomTokenError()
object FieldIsNotEmpty : AddCustomTokenError()
object InvalidContractAddress : AddCustomTokenError()
object NetworkIsNotSelected : AddCustomTokenError()
object InvalidDecimalsCount : AddCustomTokenError()
object InvalidDerivationPath : AddCustomTokenError()
sealed class Network : AddCustomTokenError() {
object CheckAddressRequestError : Network()
}
sealed class Warning : AddCustomTokenError() {
object PotentialScamToken : Warning()
object TokenAlreadyAdded : Warning()
object UnsupportedSolanaToken : Warning()
}
data class SelectTokeNetworkError(val networkId: String) :
AddCustomTokenError(
message = "Unknown network [$networkId] should not be included in the network selection dialog.",
),
FbConsumeException
data class UnAppropriateInitialization(
val of: String,
val info: String? = null,
) : AddCustomTokenError(
message = "The [$of], must be properly initialized. Info [$info]",
)
}

View file

@ -0,0 +1,39 @@
package com.tangem.domain
import com.tangem.blockchain.common.DerivationStyle
/**
[REDACTED_AUTHOR]
* Provides a temporary copies of the app module classes, data structures, etc.
*/
// TODO: refactoring: : after refactoring they should be unwrapped and moved
// to appropriate parts of module
sealed interface DomainWrapped {
// Mirror reflection ot the com.tangem.tap.features.wallet.redux.Currency
sealed interface Currency {
val blockchain: com.tangem.blockchain.common.Blockchain
val currencySymbol: String
val derivationPath: String?
data class Token(
val token: com.tangem.blockchain.common.Token,
override val blockchain: com.tangem.blockchain.common.Blockchain,
override val derivationPath: String?,
) : Currency {
override val currencySymbol = token.symbol
}
data class Blockchain(
override val blockchain: com.tangem.blockchain.common.Blockchain,
override val derivationPath: String?,
) : Currency {
override val currencySymbol: String = blockchain.currency
}
fun isCustomCurrency(derivationStyle: DerivationStyle?): Boolean {
if (derivationPath == null || derivationStyle == null) return false
return derivationPath != blockchain.derivationPath(derivationStyle)?.rawPath
}
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.common
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
interface CardTypesResolver {
fun isTangemNote(): Boolean
fun isTangemWallet(): Boolean
fun isWallet2(): Boolean
fun isSaltPay(): Boolean
fun isSaltPayVisa(): Boolean
fun isSaltPayWallet(): Boolean
fun isTangemTwins(): Boolean
fun isStart2Coin(): Boolean
fun isMultiwalletAllowed(): Boolean
fun getBlockchain(): Blockchain
fun getPrimaryToken(): Token?
}

View file

@ -0,0 +1,26 @@
package com.tangem.domain.common
import com.tangem.domain.features.BuildConfig
object LogConfig {
const val imageLoader: Boolean = false
val storeAction: Boolean = BuildConfig.DEBUG
const val zendesk: Boolean = false
val network: NetworkLogConfig = NetworkLogConfig
val analyticsHandlers: AnalyticsHandlersLogConfig = AnalyticsHandlersLogConfig
}
object NetworkLogConfig {
const val mercuryoService: Boolean = false
const val moonPayService: Boolean = false
const val utorgService: Boolean = false
val tangemTechService: Boolean = BuildConfig.DEBUG
val paymentologyApiService: Boolean = BuildConfig.DEBUG
val blockchainSdkNetwork: Boolean = BuildConfig.DEBUG
}
object AnalyticsHandlersLogConfig {
const val firebase: Boolean = false
const val appsFlyer: Boolean = false
const val amplitude: Boolean = false
}

View file

@ -0,0 +1,84 @@
package com.tangem.domain.common
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.common.CardIdRange
import com.tangem.common.contains
object SaltPayWorkaround {
@Suppress("MagicNumber")
val visaBatches = listOf(
"AE02",
"AE03",
) + attachTestVisaBatches()
val walletCardIds = listOf(
"AC01000000033503",
"AC01000000033594",
"AC01000000033586",
"AC01000000034477",
"AC01000000032760",
"AC01000000033867",
"AC01000000032653",
"AC01000000032752",
"AC01000000034485",
"AC01000000033644",
"AC01000000037454",
"AC01000000037462",
"AC03000000076070",
"AC03000000076088",
"AC03000000076096",
"AC03000000076104",
"AC03000000076112",
"AC03000000076120",
"AC03000000076138",
"AC03000000076146",
"AC03000000076153",
"AC03000000076161",
"AC03000000076179",
"AC03000000076187",
"AC03000000076195",
"AC03000000076203",
"AC03000000076211",
"AC03000000076229",
) + attachTestWalletCardIds()
val walletCardIdRanges = listOf(
CardIdRange("AC05000000000003", "AC05000000023997")!!,
) + attachTestWalletCardIdRanges()
fun tokenFrom(blockchain: Blockchain): Token {
return when (blockchain) {
Blockchain.SaltPay -> Token(
name = "WXDAI",
symbol = "wxDAI",
contractAddress = "0x4200000000000000000000000000000000000006",
decimals = 18,
id = "wrapped-xdai",
)
else -> error("It is not SaltPay")
}
}
@Suppress("MagicNumber")
fun isSaltPayCardId(cardId: String): Boolean = isVisaBatchId(cardId.take(4)) || isWalletCardId(cardId)
fun isVisaBatchId(batchId: String): Boolean = visaBatches.contains(batchId)
fun isWalletCardId(cardId: String): Boolean {
return if (walletCardIds.contains(cardId)) true else walletCardIdRanges.contains(cardId)
}
private fun attachTestVisaBatches(): List<String> = listOf(
"FF03",
)
private fun attachTestWalletCardIds(): List<String> = listOf(
"FF04000000000232",
)
private fun attachTestWalletCardIdRanges(): List<CardIdRange> = listOf(
CardIdRange("FF04000000000000", "FF04999999999999")!!,
)
}

View file

@ -0,0 +1,85 @@
package com.tangem.domain.common
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.common.card.EllipticCurve
import com.tangem.common.card.FirmwareVersion
import com.tangem.common.card.WalletData
import com.tangem.domain.common.TapWorkarounds.getTangemNoteBlockchain
import com.tangem.domain.common.TapWorkarounds.isSaltPay
import com.tangem.domain.common.TapWorkarounds.isSaltPayVisa
import com.tangem.domain.common.TapWorkarounds.isSaltPayWallet
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ProductType
class TangemCardTypesResolver(
private val card: CardDTO,
private val productType: ProductType,
private val walletData: WalletData?,
) : CardTypesResolver {
override fun isTangemNote(): Boolean = productType == ProductType.Note
override fun isTangemWallet(): Boolean = card.settings.isBackupAllowed &&
card.settings.isHDWalletAllowed &&
card.firmwareVersion >= FirmwareVersion.MultiWalletAvailable &&
!card.isSaltPay
override fun isWallet2(): Boolean = card.firmwareVersion >= FirmwareVersion.KeysImportAvailable
override fun isSaltPay(): Boolean = productType == ProductType.SaltPay
override fun isSaltPayVisa(): Boolean = card.isSaltPayVisa
override fun isSaltPayWallet(): Boolean = card.isSaltPayWallet
override fun isTangemTwins(): Boolean = productType == ProductType.Twins
override fun isStart2Coin(): Boolean = card.isStart2Coin
override fun isMultiwalletAllowed(): Boolean =
!isTangemTwins() && !card.isStart2Coin && !isTangemNote() && !isSaltPay() &&
(
card.firmwareVersion >= FirmwareVersion.MultiWalletAvailable ||
card.wallets.firstOrNull()?.curve == EllipticCurve.Secp256k1
)
override fun getBlockchain(): Blockchain {
return when (productType) {
ProductType.Start2Coin -> if (card.isTestCard) Blockchain.BitcoinTestnet else Blockchain.Bitcoin
ProductType.SaltPay -> Blockchain.SaltPay
else -> {
val blockchainName: String = walletData?.blockchain
?: if (productType == ProductType.Note) {
return card.getTangemNoteBlockchain() ?: Blockchain.Unknown
} else {
return Blockchain.Unknown
}
Blockchain.fromBlockchainName(blockchainName)
}
}
}
override fun getPrimaryToken(): Token? {
if (isSaltPay()) return SaltPayWorkaround.tokenFrom(getBlockchain())
val cardToken = walletData?.token ?: return null
return Token(
cardToken.name,
cardToken.symbol,
cardToken.contractAddress,
cardToken.decimals,
)
}
}
private fun Blockchain.Companion.fromBlockchainName(blockchainName: String): Blockchain {
// workaround for BSC (BNB) notes cards
return when (blockchainName) {
"BINANCE" -> {
Blockchain.BSC
}
"BINANCE/test" -> {
Blockchain.BSCTestnet
}
else -> {
Blockchain.fromId(blockchainName)
}
}
}

View file

@ -0,0 +1,90 @@
package com.tangem.domain.common
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.DerivationStyle
import com.tangem.common.card.Card
import com.tangem.domain.models.scan.CardDTO
import java.util.*
/**
[REDACTED_AUTHOR]
*/
object TapWorkarounds {
private const val START_2_COIN_ISSUER = "start2coin"
private const val TEST_CARD_BATCH = "99FF"
private const val TEST_CARD_ID_STARTS_WITH = "FF99"
val CardDTO.isTangemTwins: Boolean
get() = TwinsHelper.getTwinCardNumber(cardId) != null
// TODO: replace by reading files from a card
val CardDTO.isTangemNote: Boolean
get() = tangemNoteBatches.contains(batchId)
val CardDTO.isStart2Coin: Boolean
get() = isStart2CoinIssuer(issuer.name)
val CardDTO.isSaltPay: Boolean
get() = isSaltPayVisa || isSaltPayWallet
val CardDTO.isSaltPayVisa: Boolean
get() = SaltPayWorkaround.isVisaBatchId(batchId)
val CardDTO.isSaltPayWallet: Boolean
get() = SaltPayWorkaround.isWalletCardId(cardId)
val CardDTO.isTestCard: Boolean
get() = batchId == TEST_CARD_BATCH && cardId.startsWith(TEST_CARD_ID_STARTS_WITH)
val CardDTO.useOldStyleDerivation: Boolean
get() = batchId == "AC01" || batchId == "AC02" || batchId == "CB95"
val CardDTO.derivationStyle: DerivationStyle?
get() = if (!settings.isHDWalletAllowed) {
null
} else if (useOldStyleDerivation) {
DerivationStyle.LEGACY
} else {
DerivationStyle.NEW
}
val CardDTO.isExcluded: Boolean
get() {
val excludedBatch = excludedBatches.contains(batchId)
val excludedIssuerName = excludedIssuers.contains(issuer.name.uppercase(Locale.ROOT))
return excludedBatch || excludedIssuerName
}
val CardDTO.isNotSupportedInThatRelease: Boolean
get() = false
private val tangemNoteBatches = mapOf(
"AB01" to Blockchain.Bitcoin,
"AB02" to Blockchain.Ethereum,
"AB03" to Blockchain.CardanoShelley,
"AB04" to Blockchain.Dogecoin,
"AB05" to Blockchain.BSC,
"AB06" to Blockchain.XRP,
"AB07" to Blockchain.Bitcoin,
"AB08" to Blockchain.Ethereum,
"AB09" to Blockchain.Bitcoin, // new batches for 3.34
"AB10" to Blockchain.Ethereum,
"AB11" to Blockchain.Bitcoin,
"AB12" to Blockchain.Ethereum,
)
private val excludedBatches = listOf("0027", "0030", "0031", "0035")
private val excludedIssuers = listOf("TTM BANK")
@Deprecated(
"Now blockchain is read form files (CardTypesResolver.getBlockchain), " +
"but for previously saved cards this method is still used",
)
fun CardDTO.getTangemNoteBlockchain(): Blockchain? = tangemNoteBatches[batchId]
fun isStart2CoinIssuer(cardIssuer: String?): Boolean {
return cardIssuer?.lowercase(Locale.US) == START_2_COIN_ISSUER
}
fun Card.getTangemNoteBlockchain(): Blockchain? = tangemNoteBatches[batchId] ?: null
}

View file

@ -0,0 +1,64 @@
package com.tangem.domain.common
/**
[REDACTED_AUTHOR]
*/
open class Throttler<T>(
private val duration: Long,
) : Throttle<T> {
private val items: MutableMap<T, Long> = mutableMapOf()
override fun isStillThrottled(item: T): Boolean {
val inThrottlingUpTo = items[item] ?: return false
val diff = System.currentTimeMillis() - inThrottlingUpTo
return diff < 0
}
override fun updateThrottlingTo(item: T): T {
val now = System.currentTimeMillis()
val throttledUpTo = items[item] ?: 0L
if (throttledUpTo == 0L || throttledUpTo < now) {
val newTime = now + duration
items[item] = newTime
}
return item
}
open fun clear() {
items.clear()
}
}
class ThrottlerWithValues<T, V>(
duration: Long,
) : Throttler<T>(duration), ValuesHolder<T, V> {
private val valuesHolder: MutableMap<T, V?> = mutableMapOf()
override fun setValue(item: T, value: V) {
valuesHolder[item] = value
}
override fun geValue(item: T): V? = valuesHolder[item]
override fun remove(item: T) {
valuesHolder.remove(item)
}
override fun clear() {
valuesHolder.clear()
super.clear()
}
}
interface Throttle<T> {
fun isStillThrottled(item: T): Boolean
fun updateThrottlingTo(item: T): T
}
interface ValuesHolder<K, V> {
fun setValue(item: K, value: V)
fun geValue(item: K): V?
fun remove(item: K)
}

View file

@ -0,0 +1,55 @@
package com.tangem.domain.common
import com.tangem.crypto.CryptoUtils
import com.tangem.domain.models.scan.CardDTO
object TwinsHelper {
private val firstCardSeries = listOf("CB61", "CB64")
private val secondCardSeries = listOf("CB62", "CB65")
@Suppress("MagicNumber")
fun verifyTwinPublicKey(issuerData: ByteArray, cardWalletPublicKey: ByteArray?): Boolean {
if (issuerData.size < 65 || cardWalletPublicKey == null) return false
val publicKey = issuerData.sliceArray(0 until 65)
val signedKey = issuerData.sliceArray(65 until issuerData.size)
return CryptoUtils.verify(cardWalletPublicKey, publicKey, signedKey)
}
fun getTwinCardNumber(cardId: String): TwinCardNumber? = when {
firstCardSeries.any(cardId::startsWith) -> TwinCardNumber.First
secondCardSeries.any(cardId::startsWith) -> TwinCardNumber.Second
else -> null
}
@Suppress("MagicNumber")
fun getTwinCardIdForUser(cardId: String): String {
if (cardId.length < 16) return cardId
val twinCardId = cardId.substring(11..14)
val twinCardNumber = getTwinCardNumber(cardId)?.number ?: 1
return "$twinCardId #$twinCardNumber"
}
}
enum class TwinCardNumber(val number: Int) {
First(1), Second(2);
fun pairNumber(): TwinCardNumber = when (this) {
First -> Second
Second -> First
}
}
@Deprecated("Use ScanResponse.isTangemTwin")
fun CardDTO.isTangemTwin(): Boolean {
return TwinsHelper.getTwinCardNumber(cardId) != null
}
fun CardDTO.getTwinCardNumber(): TwinCardNumber? {
return TwinsHelper.getTwinCardNumber(this.cardId)
}
fun CardDTO.getTwinCardIdForUser(): String {
return TwinsHelper.getTwinCardIdForUser(this.cardId)
}

View file

@ -0,0 +1,412 @@
package com.tangem.domain.common.demo
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.features.BuildConfig
import java.math.BigDecimal
@Suppress("LargeClass")
class DemoConfig {
val demoBlockchains = listOf(
Blockchain.Bitcoin,
Blockchain.Ethereum,
Blockchain.Dogecoin,
Blockchain.Solana,
)
val demoCardIds: List<String> by lazy {
val demoIds = getReleaseIds().toMutableList()
if (BuildConfig.DEBUG) demoIds.addAll(debugTestDemoCardIds)
return@lazy demoIds.distinct()
}
private val walletBalances: Map<Blockchain, Amount> = mapOf(
Blockchain.Bitcoin to Amount(0.028.toBigDecimal(), Blockchain.Bitcoin),
Blockchain.Ethereum to Amount(0.2311.toBigDecimal(), Blockchain.Ethereum),
Blockchain.Dogecoin to Amount(1450.025.toBigDecimal(), Blockchain.Dogecoin),
Blockchain.Solana to Amount(13.246.toBigDecimal(), Blockchain.Solana),
)
fun isDemoCardId(cardId: String): Boolean = demoCardIds.contains(cardId)
fun isTestDemoCardId(cardId: String): Boolean = testDemoCardIds.contains(cardId)
fun getBalance(blockchain: Blockchain): Amount = walletBalances[blockchain]?.copy()
?: Amount(BigDecimal.ZERO, blockchain).copy()
private fun getReleaseIds(): List<String> {
return (releaseDemoCardIds + testDemoCardIds).distinct()
}
@Suppress("ClassOrdering")
private val releaseDemoCardIds = mutableListOf(
// === Not from the Google Sheet table ===
"AC01000000041225",
"AC01000000041472",
"AB01000000046498",
"AB01000000049608",
"AB01000000049574",
"AB01000000046704",
"AB02000000051000",
"AB02000000050911",
// === Mvideo ===
// Wallet
"AC01000000045754",
"AC01000000041662",
"AC01000000041647",
"AC01000000041209",
"AC01000000042462",
"AC01000000041100",
"AC01000000041621",
"AC01000000045960",
"AC01000000041092",
"AC01000000041217",
"AC01000000013489",
"AC01000000028610",
"AC01000000028701",
"AC01000000028578",
"AC01000000027281",
"AC01000000027216",
"AC01000000028594",
"AC01000000028602",
"AC01000000028636",
"AC01000000013968",
"AC01000000027208",
"AC01000000013471",
"AC01000000028586",
"AC01000000013703",
"AC01000000028628",
"AC01000000028693",
"AC01000000028685",
"AC01000000013950",
"AC01000000013828",
"AC01000000013497",
"AC01000000013836",
"AC01000000013505",
"AC03000000046693",
"AC03000000046685",
"AC03000000046677",
"AC03000000046669",
"AC03000000046651",
"AC03000000046644",
"AC03000000046636",
"AC03000000046628",
"AC03000000046610",
"AC03000000046602",
"AC03000000046594",
"AC03000000046586",
"AC03000000046578",
"AC03000000046560",
"AC03000000046552",
"AC03000000046545",
"AC03000000046537",
"AC03000000046529",
"AC03000000046511",
"AC03000000046800",
"AC03000000046792",
"AC03000000046784",
"AC03000000046776",
"AC03000000046768",
"AC03000000046750",
"AC03000000046743",
"AC03000000046735",
"AC03000000046727",
"AC03000000046446",
"AC03000000046438",
"AC03000000046412",
"AC03000000046388",
"AC03000000046370",
"AC03000000046354",
"AC03000000046347",
"AC03000000046339",
"AC03000000046321",
"AC03000000046172",
"AC03000000046396",
"AC03000000046404",
"AC03000000046701",
"AC03000000046420",
"AC03000000046719",
"AC03000000046503",
"AC03000000046495",
"AC03000000046487",
"AC03000000046362",
"AC03000000046479",
"AC03000000046461",
"AC03000000046453",
// Note BTC
"AB01000000059608",
"AB01000000046647",
"AB01000000046571",
"AB01000000046746",
"AB01000000059574",
"AB01000000046753",
"AB01000000046605",
"AB01000000046761",
"AB01000000046720",
"AB01000000046530",
"AB01000000016475",
"AB01000000016483",
"AB01000000016491",
"AB01000000020709",
"AB01000000020717",
"AB01000000015550",
"AB01000000015394",
"AB01000000016079",
"AB01000000016087",
"AB01000000016095",
"AB01000000020915",
"AB01000000017184",
"AB01000000020907",
"AB01000000017192",
"AB01000000016210",
"AB01000000016111",
"AB01000000016103",
"AB01000000015766",
"AB01000000015774",
"AB01000000015782",
"AB01000000022598",
"AB01000000022580",
"AB01000000005688",
"AB07000000005696",
"AB07000000005902",
"AB07000000005910",
"AB07000000005928",
"AB07000000005936",
"AB07000000005944",
"AB07000000005993",
"AB07000000005985",
"AB07000000005977",
"AB07000000005969",
"AB07000000005951",
"AB07000000005605",
"AB07000000005803",
"AB07000000005811",
"AB07000000005829",
"AB07000000005837",
"AB07000000005845",
"AB07000000005852",
"AB07000000005860",
"AB07000000005878",
"AB07000000005886",
"AB07000000005894",
"AB07000000005704",
"AB07000000005712",
"AB07000000005720",
"AB07000000005738",
"AB07000000005746",
"AB07000000005514",
"AB07000000005522",
"AB07000000005563",
"AB07000000005571",
"AB07000000005589",
"AB07000000005597",
"AB07000000005613",
"AB07000000005621",
"AB07000000005639",
"AB07000000005647",
"AB07000000005654",
"AB07000000005662",
"AB07000000005670",
"AB07000000005530",
"AB07000000005548",
"AB07000000005555",
"AB07000000005753",
"AB07000000005761",
"AB07000000005779",
"AB07000000005787",
"AB07000000005795",
"AB07000000005506",
// Note ETH
"AB02000000051083",
"AB02000000051059",
"AB02000000051158",
"AB02000000050986",
"AB02000000051026",
"AB02000000050960",
"AB02000000051042",
"AB02000000051091",
"AB02000000051034",
"AB02000000051133",
"AB02000000019924",
"AB02000000019932",
"AB02000000022092",
"AB02000000022282",
"AB02000000023983",
"AB02000000023439",
"AB02000000020328",
"AB02000000020310",
"AB02000000021565",
"AB02000000022357",
"AB02000000023355",
"AB02000000022324",
"AB02000000022100",
"AB02000000019999",
"AB02000000020013",
"AB02000000020005",
"AB02000000020021",
"AB02000000020039",
"AB02000000020278",
"AB02000000020252",
"AB02000000018652",
"AB02000000018561",
"AB08000000009481",
"AB08000000009473",
"AB08000000009705",
"AB08000000009897",
"AB08000000009689",
"AB08000000009671",
"AB08000000009465",
"AB08000000009457",
"AB08000000009440",
"AB08000000009432",
"AB08000000009424",
"AB08000000009416",
"AB08000000009408",
"AB08000000009390",
"AB08000000009374",
"AB08000000009382",
"AB08000000009267",
"AB08000000009275",
"AB08000000009283",
"AB08000000009291",
"AB08000000009309",
"AB08000000009317",
"AB08000000009325",
"AB08000000009333",
"AB08000000009341",
"AB08000000009358",
"AB08000000009366",
"AB08000000009077",
"AB08000000009143",
"AB08000000009168",
"AB08000000009184",
"AB08000000009192",
"AB08000000009200",
"AB08000000009226",
"AB08000000009218",
"AB08000000009234",
"AB08000000009242",
"AB08000000008574",
"AB08000000009069",
"AB08000000008525",
"AB08000000009051",
"AB08000000009135",
"AB08000000009150",
"AB08000000009176",
"AB08000000009085",
"AB08000000009093",
"AB08000000009101",
"AB08000000009119",
"AB08000000009127",
"AB08000000009259",
// === Technopark ===
// Wallet
"AC01000000044120",
"AC01000000044997",
"AC01000000044989",
"AC01000000043494",
"AC01000000043486",
"AC01000000044187",
"AC01000000043148",
"AC01000000044013",
"AC01000000043973",
"AC01000000044815",
"AC01000000044807",
"AC01000000043809",
"AC01000000043833",
"AC01000000043460",
"AC01000000043064",
"AC01000000044138",
"AC01000000044500",
"AC01000000044492",
"AC01000000044260",
"AC01000000044278",
// Note BTC
"AB01000000049864",
"AB01000000053239",
"AB01000000053056",
"AB01000000054237",
"AB01000000054245",
"AB01000000054211",
"AB01000000054229",
"AB01000000053189",
"AB01000000054195",
"AB01000000050797",
"AB01000000053833",
"AB01000000052124",
"AB01000000051605",
"AB01000000052223",
"AB01000000052207",
"AB01000000052199",
"AB01000000047785",
"AB01000000047850",
"AB01000000047868",
"AB01000000048288",
// Note ETH
"AB02000000049715",
"AB02000000049848",
"AB02000000049814",
"AB02000000049863",
"AB02000000049871",
"AB02000000049855",
"AB02000000049285",
"AB02000000049277",
"AB02000000049558",
"AB02000000049889",
"AB02000000049988",
"AB02000000049707",
"AB02000000049699",
"AB02000000049897",
"AB02000000049905",
"AB02000000049913",
"AB02000000049251",
"AB02000000049533",
"AB02000000049541",
"AB02000000049830",
// === more cids ===
"AC03000000091418",
"AC03000000091400",
"AC03000000099007",
"AC03000000098991",
"AC03000000098942",
"AC03000000091715",
"AC03000000091301",
"AC03000000091343",
"AB01000000055705",
"AB01000000052918",
"AB01000000047710",
"AB01000000052306",
"AB01000000047645",
"AB01000000048957",
"AB01000000052900",
"AB01000000050391",
"AB01000000047363",
"AB02000000053998",
"AB02000000019809",
"AB02000000020872",
"AB02000000022027",
"AB02000000058955",
"AB02000000053253",
"AB02000000048063",
"AB02000000023736",
"AB02000000058187",
)
@Suppress("ClassOrdering")
private val testDemoCardIds = listOf(
"FB20000000000186", // Note ETH
"FB10000000000196", // Note BTC
"FB30000000000176", // Wallet
)
@Suppress("ClassOrdering")
private val debugTestDemoCardIds = listOf<String>()
}

View file

@ -0,0 +1,166 @@
package com.tangem.domain.common.extensions
import com.tangem.blockchain.common.Blockchain
@Suppress("ComplexMethod")
fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain? {
return when (networkId) {
"arbitrum-one" -> Blockchain.Arbitrum
"arbitrum-one/test" -> Blockchain.ArbitrumTestnet
"avalanche", "avalanche-2" -> Blockchain.Avalanche
"avalanche/test", "avalanche-2/test" -> Blockchain.AvalancheTestnet
"binancecoin" -> Blockchain.Binance
"binancecoin/test" -> Blockchain.BinanceTestnet
"binance-smart-chain" -> Blockchain.BSC
"binance-smart-chain/test" -> Blockchain.BSCTestnet
"ethereum" -> Blockchain.Ethereum
"ethereum/test" -> Blockchain.EthereumTestnet
"ethereum-classic" -> Blockchain.EthereumClassic
"ethereum-classic/test" -> Blockchain.EthereumClassicTestnet
"polygon-pos", "matic-network" -> Blockchain.Polygon
"polygon-pos/test", "matic-network/test" -> Blockchain.PolygonTestnet
"solana" -> Blockchain.Solana
"solana/test" -> Blockchain.SolanaTestnet
"fantom" -> Blockchain.Fantom
"fantom/test" -> Blockchain.FantomTestnet
"bitcoin" -> Blockchain.Bitcoin
"bitcoin/test" -> Blockchain.BitcoinTestnet
"bitcoin-cash" -> Blockchain.BitcoinCash
"bitcoin-cash/test" -> Blockchain.BitcoinCashTestnet
"cardano" -> Blockchain.CardanoShelley
"dogecoin" -> Blockchain.Dogecoin
"ducatus" -> Blockchain.Ducatus
"litecoin" -> Blockchain.Litecoin
"rootstock" -> Blockchain.RSK
"stellar" -> Blockchain.Stellar
"stellar/test" -> Blockchain.StellarTestnet
"tezos" -> Blockchain.Tezos
"tron" -> Blockchain.Tron
"tron/test" -> Blockchain.TronTestnet
"xrp", "ripple" -> Blockchain.XRP
"xdai" -> Blockchain.Gnosis
"ethereum-pow-iou" -> Blockchain.EthereumPow
"ethereum-pow-iou/test" -> Blockchain.EthereumPowTestnet
"ethereumfair" -> Blockchain.EthereumFair
"polkadot" -> Blockchain.Polkadot
"polkadot/test" -> Blockchain.PolkadotTestnet
"kusama" -> Blockchain.Kusama
"optimistic-ethereum" -> Blockchain.Optimism
"optimistic-ethereum/test" -> Blockchain.OptimismTestnet
"dash" -> Blockchain.Dash
"sxdai" -> Blockchain.SaltPay
"kaspa" -> Blockchain.Kaspa
"the-open-network" -> Blockchain.TON
"the-open-network/test" -> Blockchain.TONTestnet
"kava" -> Blockchain.Kava
"kava/test" -> Blockchain.KavaTestnet
"ravencoin" -> Blockchain.Ravencoin
"ravencoin/test" -> Blockchain.RavencoinTestnet
else -> null
}
}
@Suppress("ComplexMethod")
fun Blockchain.toNetworkId(): String {
return when (this) {
Blockchain.Unknown -> "unknown"
Blockchain.Arbitrum -> "arbitrum-one"
Blockchain.ArbitrumTestnet -> "arbitrum-one/test"
Blockchain.Avalanche -> "avalanche"
Blockchain.AvalancheTestnet -> "avalanche/test"
Blockchain.Binance -> "binancecoin"
Blockchain.BinanceTestnet -> "binancecoin/test"
Blockchain.BSC -> "binance-smart-chain"
Blockchain.BSCTestnet -> "binance-smart-chain/test"
Blockchain.Bitcoin -> "bitcoin"
Blockchain.BitcoinTestnet -> "bitcoin/test"
Blockchain.BitcoinCash -> "bitcoin-cash"
Blockchain.BitcoinCashTestnet -> "bitcoin-cash/test"
Blockchain.Cardano -> "cardano"
Blockchain.CardanoShelley -> "cardano"
Blockchain.Dogecoin -> "dogecoin"
Blockchain.Ducatus -> "ducatus"
Blockchain.Ethereum -> "ethereum"
Blockchain.EthereumTestnet -> "ethereum/test"
Blockchain.EthereumClassic -> "ethereum-classic"
Blockchain.EthereumClassicTestnet -> "ethereum-classic/test"
Blockchain.Fantom -> "fantom"
Blockchain.FantomTestnet -> "fantom/test"
Blockchain.Litecoin -> "litecoin"
Blockchain.Polygon -> "polygon-pos"
Blockchain.PolygonTestnet -> "polygon-pos/test"
Blockchain.RSK -> "rootstock"
Blockchain.Stellar -> "stellar"
Blockchain.StellarTestnet -> "stellar/test"
Blockchain.Solana -> "solana"
Blockchain.SolanaTestnet -> "solana/test"
Blockchain.Tezos -> "tezos"
Blockchain.XRP -> "xrp"
Blockchain.Tron -> "tron"
Blockchain.TronTestnet -> "tron/test"
Blockchain.Gnosis -> "xdai"
Blockchain.EthereumPow -> "ethereum-pow-iou"
Blockchain.EthereumPowTestnet -> "ethereum-pow-iou/test"
Blockchain.EthereumFair -> "ethereumfair"
Blockchain.Polkadot -> "polkadot"
Blockchain.PolkadotTestnet -> "polkadot/test"
Blockchain.Kusama -> "kusama"
Blockchain.Optimism -> "optimistic-ethereum"
Blockchain.OptimismTestnet -> "optimistic-ethereum/test"
Blockchain.Dash -> "dash"
Blockchain.SaltPay -> "sxdai"
Blockchain.Kaspa -> "kaspa"
Blockchain.TON -> "the-open-network"
Blockchain.TONTestnet -> "the-open-network/test"
Blockchain.Kava -> "kava"
Blockchain.KavaTestnet -> "kava/test"
Blockchain.Ravencoin -> "ravencoin"
Blockchain.RavencoinTestnet -> "ravencoin/test"
}
}
@Suppress("ComplexMethod")
fun Blockchain.toCoinId(): String {
return when (this) {
Blockchain.Binance, Blockchain.BinanceTestnet, Blockchain.BSC, Blockchain.BSCTestnet -> "binancecoin"
Blockchain.Bitcoin, Blockchain.BitcoinTestnet -> "bitcoin"
Blockchain.BitcoinCash, Blockchain.BitcoinCashTestnet -> "bitcoin-cash"
Blockchain.Ethereum, Blockchain.EthereumTestnet -> "ethereum"
Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet -> "ethereum-classic"
Blockchain.Stellar, Blockchain.StellarTestnet -> "stellar"
Blockchain.Cardano, Blockchain.CardanoShelley -> "cardano"
Blockchain.Polygon, Blockchain.PolygonTestnet -> "matic-network"
Blockchain.Arbitrum, Blockchain.ArbitrumTestnet -> "ethereum"
Blockchain.Avalanche, Blockchain.AvalancheTestnet -> "avalanche-2"
Blockchain.Solana, Blockchain.SolanaTestnet -> "solana"
Blockchain.Fantom, Blockchain.FantomTestnet -> "fantom"
Blockchain.Tron, Blockchain.TronTestnet -> "tron"
Blockchain.Polkadot, Blockchain.PolkadotTestnet -> "polkadot"
Blockchain.Ducatus -> "ducatus"
Blockchain.Litecoin -> "litecoin"
Blockchain.RSK -> "rootstock"
Blockchain.Tezos -> "tezos"
Blockchain.XRP -> "ripple"
Blockchain.Dogecoin -> "dogecoin"
Blockchain.Gnosis -> "xdai"
Blockchain.EthereumPow, Blockchain.EthereumPowTestnet -> "ethereum-pow-iou"
Blockchain.EthereumFair -> "ethereumfair"
Blockchain.Kusama -> "kusama"
Blockchain.Optimism, Blockchain.OptimismTestnet -> "ethereum"
Blockchain.Dash -> "dash"
Blockchain.SaltPay -> "xdai"
Blockchain.Kaspa -> "kaspa"
Blockchain.TON, Blockchain.TONTestnet -> "the-open-network"
Blockchain.Unknown -> "unknown"
Blockchain.Kava, Blockchain.KavaTestnet -> "kava"
Blockchain.Ravencoin, Blockchain.RavencoinTestnet -> "ravencoin"
}
}
fun Blockchain.isSupportedInApp(): Boolean {
return !excludedBlockchains.contains(this)
}
private val excludedBlockchains = listOf(
Blockchain.SaltPay,
)

View file

@ -0,0 +1,10 @@
package com.tangem.domain.common.extensions
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
fun ByteArray.calculateHmacSha256(key: ByteArray): ByteArray {
val mac: Mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac.doFinal(this)
}

View file

@ -0,0 +1,44 @@
package com.tangem.domain.common.extensions
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.card.EllipticCurve
import com.tangem.common.card.FirmwareVersion
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.models.scan.CardDTO
/**
[REDACTED_AUTHOR]
*/
val FirmwareVersion.Companion.SolanaTokensAvailable
get() = FirmwareVersion(4, 52)
fun CardDTO.supportedBlockchains(): List<Blockchain> {
val supportedBlockchains = if (firmwareVersion < FirmwareVersion.MultiWalletAvailable) {
Blockchain.fromCurve(EllipticCurve.Secp256k1)
} else {
wallets.flatMap { Blockchain.fromCurve(it.curve) }.distinct()
}
return supportedBlockchains
.filter { isTestCard == it.isTestnet() }
.filter { it.isSupportedInApp() }
}
fun CardDTO.supportedTokens(): List<Blockchain> {
val tokensSupportedByBlockchain = supportedBlockchains().filter { it.canHandleTokens() }.toMutableList()
val tokensSupportedByCard = when {
firmwareVersion >= FirmwareVersion.SolanaTokensAvailable -> tokensSupportedByBlockchain
else -> {
tokensSupportedByBlockchain.apply {
remove(Blockchain.Solana)
remove(Blockchain.SolanaTestnet)
}
}
}
return tokensSupportedByCard.filter { isTestCard == it.isTestnet() }
}
fun CardDTO.canHandleToken(blockchain: Blockchain): Boolean {
return this.supportedTokens().contains(blockchain)
}

View file

@ -0,0 +1,32 @@
package com.tangem.domain.common.extensions
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
[REDACTED_AUTHOR]
*/
suspend fun <T> withMainContext(block: suspend CoroutineScope.() -> T): T = withContext(Dispatchers.Main, block)
suspend fun <T> withIOContext(block: suspend CoroutineScope.() -> T): T = withContext(Dispatchers.IO, block)
fun <T> debounce(
waitMs: Long = 300L,
coroutineScope: CoroutineScope,
runDestinationOn: CoroutineDispatcher = Dispatchers.Unconfined,
destinationFunction: (T) -> Unit,
): (T) -> Unit {
var debounceJob: Job? = null
return { param: T ->
debounceJob?.cancel()
debounceJob = coroutineScope.launch {
delay(waitMs)
withContext(runDestinationOn) { destinationFunction(param) }
}
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.domain.common.extensions
import com.tangem.common.CompletionResult
import com.tangem.common.services.Result
/**
[REDACTED_AUTHOR]
*/
inline fun <T> Result<T>.successOr(failureClause: (Result.Failure) -> T): T {
return when (this) {
is Result.Success -> this.data
is Result.Failure -> failureClause(this)
}
}
inline fun <T> CompletionResult<T>.successOr(failureClause: (CompletionResult.Failure<T>) -> T): T {
return when (this) {
is CompletionResult.Success -> this.data
is CompletionResult.Failure -> failureClause(this)
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.domain.common.form
import com.tangem.common.json.MoshiJsonConverter
/**
[REDACTED_AUTHOR]
*/
interface DataConverterVisitor<Data, Result> {
fun visit(data: Data?)
fun getConvertedData(): Result
}
interface FieldDataConverter<Result> : DataConverterVisitor<FieldData, Result>
abstract class BaseFieldDataConverter<Result> : FieldDataConverter<Result> {
protected val collectIds: List<FieldId>
get() = getIdToCollect()
protected val collectedData: MutableMap<FieldId, Any?> = mutableMapOf()
override fun visit(data: Pair<FieldId, Field.Data<*>>?) {
val id = data?.first ?: return
if (collectIds.contains(id)) {
collectedData[id] = data.second.value
}
}
abstract fun getIdToCollect(): List<FieldId>
}
class FieldToJsonConverter(
private val fieldsToConvert: List<FieldId> = listOf(),
protected val jsonConverter: MoshiJsonConverter,
) : BaseFieldDataConverter<String>() {
override fun getConvertedData(): String = jsonConverter.toJson(collectedData, " ")
override fun getIdToCollect(): List<FieldId> = fieldsToConvert
}

View file

@ -0,0 +1,101 @@
package com.tangem.domain.common.form
import com.tangem.blockchain.blockchains.ethereum.EthereumAddressService
import com.tangem.blockchain.blockchains.solana.SolanaAddressService
import com.tangem.blockchain.blockchains.tron.TronAddressService
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.address.AddressService
import com.tangem.common.Validator
import com.tangem.common.card.EllipticCurve
import com.tangem.domain.AddCustomTokenError
import timber.log.Timber
/**
[REDACTED_AUTHOR]
*/
interface CustomTokenValidator<T> : Validator<T, AddCustomTokenError>
class StringIsEmptyValidator : CustomTokenValidator<String> {
override fun validate(data: String?): AddCustomTokenError? = when {
data == null || data.isEmpty() -> null
else -> AddCustomTokenError.FieldIsNotEmpty
}
}
class StringIsNotEmptyValidator : CustomTokenValidator<String> {
override fun validate(data: String?): AddCustomTokenError? = when {
data == null || data.isEmpty() -> AddCustomTokenError.FieldIsEmpty
else -> null
}
}
class TokenContractAddressValidator : CustomTokenValidator<String> {
private var blockchain: Blockchain = Blockchain.Unknown
private val successAddressValidator = object : AddressService() {
override fun makeAddress(walletPublicKey: ByteArray, curve: EllipticCurve?): String {
throw UnsupportedOperationException()
}
override fun validate(address: String): Boolean = true
}
fun nextValidationFor(blockchain: Blockchain) {
this.blockchain = blockchain
}
override fun validate(data: String?): AddCustomTokenError? {
if (data == null || data.isEmpty()) return AddCustomTokenError.FieldIsEmpty
return if (getAddressService().validate(data)) {
null
} else {
AddCustomTokenError.InvalidContractAddress
}
}
private fun getAddressService(): AddressService {
return when (blockchain) {
Blockchain.Unknown -> successAddressValidator
Blockchain.Binance, Blockchain.BinanceTestnet -> successAddressValidator
Blockchain.Solana, Blockchain.SolanaTestnet -> SolanaAddressService()
Blockchain.Tron, Blockchain.TronTestnet -> TronAddressService()
else -> {
if (blockchain.isEvm()) {
EthereumAddressService()
} else {
Timber.e("Throw for blockchain: ${blockchain.fullName}")
throw UnsupportedOperationException()
}
}
}
}
}
class TokenNetworkValidator : CustomTokenValidator<Blockchain> {
override fun validate(data: Blockchain?): AddCustomTokenError? = when (data) {
null, Blockchain.Unknown -> AddCustomTokenError.NetworkIsNotSelected
else -> null
}
}
class TokenNameValidator : CustomTokenValidator<String> {
override fun validate(data: String?): AddCustomTokenError? = StringIsNotEmptyValidator().validate(data)
}
class TokenSymbolValidator : CustomTokenValidator<String> {
override fun validate(data: String?): AddCustomTokenError? = StringIsNotEmptyValidator().validate(data)
}
@Suppress("MagicNumber")
class TokenDecimalsValidator : CustomTokenValidator<String> {
override fun validate(data: String?): AddCustomTokenError? {
val decimal = data?.toIntOrNull() ?: return AddCustomTokenError.FieldIsEmpty
return when {
decimal > 30 -> AddCustomTokenError.InvalidDecimalsCount
else -> null
}
}
}

View file

@ -0,0 +1,62 @@
package com.tangem.domain.common.form
/**
[REDACTED_AUTHOR]
*/
class Form(
fieldList: List<DataField<*>>,
) {
private val _fieldList: MutableList<DataField<*>> = fieldList.toMutableList()
val fieldList: List<DataField<*>>
get() = _fieldList.toList()
fun getField(id: FieldId): DataField<*>? = fieldList.firstOrNull { it.id == id }
fun getData(id: FieldId): Pair<FieldId, *>? = getField(id)?.getData()
fun setField(field: DataField<*>) {
val oldField = getField(field.id) ?: return
val oldIndexOfField = _fieldList.indexOf(oldField)
if (oldIndexOfField == -1) return
_fieldList.removeAt(oldIndexOfField)
_fieldList.add(oldIndexOfField, field)
}
// convert this form data whatever you want
fun visitDataConverter(converter: FieldDataConverter<*>) {
fieldList.forEach { it.visitDataConverter(converter) }
}
}
interface FieldId
interface Field<T> {
val id: FieldId
var data: Data<T>
data class Data<Data>(
val value: Data,
val isUserInput: Boolean,
)
}
typealias FieldData = Pair<FieldId, Field.Data<*>>
interface DataField<T> : Field<T> {
fun getData(): Pair<FieldId, Field.Data<T>>
fun visitDataConverter(dataConverter: FieldDataConverter<*>)
}
abstract class BaseDataField<T>(
override val id: FieldId,
override var data: Field.Data<T>,
) : DataField<T> {
override fun getData(): Pair<FieldId, Field.Data<T>> = id to data
override fun visitDataConverter(dataConverter: FieldDataConverter<*>) {
dataConverter.visit(getData())
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.domain.common.util
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.card.EllipticCurve
import com.tangem.common.extensions.toMapKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.common.TangemCardTypesResolver
import com.tangem.domain.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.models.scan.ScanResponse
val ScanResponse.cardTypesResolver: CardTypesResolver
get() = TangemCardTypesResolver(
card = card,
productType = productType,
walletData = walletData,
)
fun ScanResponse.twinsIsTwinned(): Boolean = card.isTangemTwins && walletData != null && secondTwinPublicKey != null
fun ScanResponse.supportsHdWallet(): Boolean = card.settings.isHDWalletAllowed
fun ScanResponse.supportsBackup(): Boolean = card.settings.isBackupAllowed
fun ScanResponse.hasDerivation(blockchain: Blockchain, rawDerivationPath: String): Boolean {
return hasDerivation(blockchain, DerivationPath(rawDerivationPath))
}
private fun ScanResponse.hasDerivation(blockchain: Blockchain, derivationPath: DerivationPath): Boolean {
val isTestnet = card.isTestCard || blockchain.isTestnet()
return when {
Blockchain.secp256k1Blockchains(isTestnet).contains(blockchain) -> {
hasDerivation(EllipticCurve.Secp256k1, derivationPath)
}
Blockchain.ed25519OnlyBlockchains(isTestnet).contains(blockchain) -> {
hasDerivation(EllipticCurve.Ed25519, derivationPath)
}
else -> false
}
}
private fun ScanResponse.hasDerivation(curve: EllipticCurve, derivationPath: DerivationPath): Boolean {
val foundWallet = card.wallets.firstOrNull { it.curve == curve }
?: return false
val extendedPublicKeysMap = derivedKeys[foundWallet.publicKey.toMapKey()] ?: return false
val extendedPublicKey = extendedPublicKeysMap[derivationPath]
return extendedPublicKey != null
}

View file

@ -0,0 +1,34 @@
package com.tangem.domain.common.util
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
class UserWalletId(
val stringValue: String,
) {
val value = stringValue.hexToBytes()
constructor(value: ByteArray?) : this(
stringValue = value?.toHexString() ?: "",
)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is UserWalletId) return false
if (stringValue != other.stringValue) return false
return true
}
override fun hashCode(): Int {
return stringValue.hashCode()
}
@Suppress("MagicNumber")
override fun toString(): String {
return with(stringValue) {
"UserWalletId(${take(3)}...${takeLast(3)})"
}
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.domain.common.util
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.onEach
/**
[REDACTED_AUTHOR]
*/
@Suppress("MagicNumber")
class ValueDebouncer<T>(
private val initialValue: T,
private val debounceDuration: Long = 400,
private val onValueChanged: (T) -> Unit,
private val onEmitValueReceived: (T) -> Unit = {},
) {
var emittedValue: T = initialValue
private set
var emitsCountBeforeDebounce: Int = 0
private set
var debounced: T = initialValue
private set
private val debounceScope: CoroutineScope = CoroutineScope(Job() + Dispatchers.Main)
private val flow = MutableStateFlow(debounced)
init {
debounceScope.launch {
flow.debounce(debounceDuration)
.onEach {
debounced = it
onValueChanged(it)
debounceScope.launch {
delay(500)
emitsCountBeforeDebounce = 0
}
}
.collect()
}
}
fun isDebounced(value: T): Boolean = this.debounced == value
fun emmit(emmitValue: T) {
emitsCountBeforeDebounce++
emittedValue = emmitValue
onEmitValueReceived.invoke(emmitValue)
debounceScope.launch { flow.emit(emmitValue) }
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.domain.features.addCustomToken
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
/**
[REDACTED_AUTHOR]
*/
class AddCustomTokenService(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val supportedTokenNetworkIds: List<String>,
) {
suspend fun findToken(
contractAddress: String,
networkId: String? = null,
active: Boolean? = null,
): List<CoinsResponse.Coin> = withContext(dispatchers.io) {
runCatching {
tangemTechApi.getCoins(
contractAddress = contractAddress,
networkIds = selectNetworksForSearch(networkId),
active = active,
)
}
.onSuccess { response ->
var coinsList = mutableListOf<CoinsResponse.Coin>()
response.coins.forEach { coin ->
val networksWithTheSameAddress = coin.networks
.filter { it.contractAddress != null || it.decimalCount != null }
.filter { it.contractAddress?.equals(contractAddress, ignoreCase = true) == true }
.filter { supportedTokenNetworkIds.contains(it.networkId) }
if (networksWithTheSameAddress.isNotEmpty()) {
val newToken = coin.copy(networks = networksWithTheSameAddress)
coinsList.add(newToken)
}
}
if (coinsList.size > 1) {
// https://tangem.slack.com/archives/GMXC6PP71/p1649672562078679
coinsList = mutableListOf(coinsList[0])
}
return@withContext coinsList
}
.onFailure {
return@withContext emptyList()
}
error("Unreachable code because runCatching must return result")
}
private fun selectNetworksForSearch(networkId: String?): String {
return networkId ?: supportedTokenNetworkIds.joinToString(",")
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.domain.features.addCustomToken
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.DerivationStyle
import com.tangem.blockchain.common.Token
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.common.form.BaseFieldDataConverter
import com.tangem.domain.common.form.FieldId
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState
/**
[REDACTED_AUTHOR]
*/
sealed class CustomCurrency(
val network: Blockchain,
val derivationPath: DerivationPath?,
) {
class CustomBlockchain(
network: Blockchain,
derivationPath: DerivationPath?,
) : CustomCurrency(network, derivationPath) {
class Converter(
private val derivationStyle: DerivationStyle?,
) : BaseFieldDataConverter<CustomBlockchain>() {
override fun getConvertedData(): CustomBlockchain {
val mainNetwork = collectedData[CustomTokenFieldId.Network] as Blockchain
val derivationPathNetwork = collectedData[CustomTokenFieldId.DerivationPath] as Blockchain
val derivationPath = AddCustomTokenState.getDerivationPath(
mainNetwork,
derivationPathNetwork,
derivationStyle,
)
return CustomBlockchain(mainNetwork, derivationPath)
}
override fun getIdToCollect(): List<FieldId> =
listOf(CustomTokenFieldId.Network, CustomTokenFieldId.DerivationPath)
}
}
class CustomToken(
val token: Token,
network: Blockchain,
derivationPath: DerivationPath?,
) : CustomCurrency(network, derivationPath) {
class Converter(
private val tokenId: String?,
private val derivationStyle: DerivationStyle?,
) : BaseFieldDataConverter<CustomToken>() {
override fun getConvertedData(): CustomToken {
val mainNetwork = collectedData[CustomTokenFieldId.Network] as Blockchain
val derivationPathNetwork = collectedData[CustomTokenFieldId.DerivationPath] as Blockchain
val derivationPath = AddCustomTokenState.getDerivationPath(
mainNetwork,
derivationPathNetwork,
derivationStyle,
)
val token = Token(
name = collectedData[CustomTokenFieldId.Name] as String,
symbol = collectedData[CustomTokenFieldId.Symbol] as String,
contractAddress = collectedData[CustomTokenFieldId.ContractAddress] as String,
decimals = (collectedData[CustomTokenFieldId.Decimals] as String).toInt(),
id = tokenId,
)
return CustomToken(
token,
collectedData[CustomTokenFieldId.Network] as Blockchain,
derivationPath,
)
}
override fun getIdToCollect(): List<FieldId> = CustomTokenFieldId.values().toList()
}
}
}

View file

@ -0,0 +1,32 @@
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
/**
[REDACTED_AUTHOR]
*/
enum class CustomTokenFieldId : FieldId {
ContractAddress,
Network,
Name,
Symbol,
Decimals,
DerivationPath,
}
data class TokenField(
override val id: FieldId,
) : BaseDataField<String>(id, Field.Data("", false))
data class TokenBlockchainField(
override val id: FieldId,
val itemList: List<Blockchain>,
) : BaseDataField<Blockchain>(id, Field.Data(Blockchain.Unknown, false))
data class TokenDerivationPathField(
override val id: FieldId,
val itemList: List<Blockchain>,
) : BaseDataField<Blockchain>(id, Field.Data(Blockchain.Unknown, false))

View file

@ -0,0 +1,66 @@
package com.tangem.domain.features.addCustomToken.redux
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.DerivationStyle
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.DomainWrapped
import com.tangem.domain.common.form.Field
import com.tangem.domain.common.form.FieldId
import com.tangem.domain.features.addCustomToken.CustomCurrency
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId
import org.rekotlin.Action
/**
[REDACTED_AUTHOR]
*/
sealed class AddCustomTokenAction : Action {
sealed class Init : AddCustomTokenAction() {
data class SetAddedCurrencies(val addedCurrencies: List<DomainWrapped.Currency>) : AddCustomTokenAction()
data class SetOnAddTokenCallback(val callback: (CustomCurrency) -> Unit) : AddCustomTokenAction()
}
object OnCreate : AddCustomTokenAction() {
data class SetDerivationStyle(val derivationStyle: DerivationStyle?) : AddCustomTokenAction()
}
object OnDestroy : AddCustomTokenAction()
// from user, ui
data class OnTokenContractAddressChanged(val contractAddress: Field.Data<String>) : AddCustomTokenAction()
data class OnTokenNetworkChanged(val blockchainNetwork: Field.Data<Blockchain>) : AddCustomTokenAction()
data class OnTokenNameChanged(val tokenName: Field.Data<String>) : AddCustomTokenAction()
data class OnTokenSymbolChanged(val tokenSymbol: Field.Data<String>) : AddCustomTokenAction()
data class OnTokenDerivationPathChanged(
val blockchainDerivationPath: Field.Data<Blockchain>,
) : AddCustomTokenAction()
data class OnTokenDecimalsChanged(val tokenDecimals: Field.Data<String>) : AddCustomTokenAction()
object OnAddCustomTokenClicked : AddCustomTokenAction()
data class SetFoundTokenInfo(val foundToken: CoinsResponse.Coin?) : AddCustomTokenAction()
// form fields
data class UpdateForm(val state: AddCustomTokenState) : AddCustomTokenAction()
sealed class FieldError : AddCustomTokenAction() {
data class Add(val id: CustomTokenFieldId, val error: AddCustomTokenError) : FieldError()
data class Remove(val id: CustomTokenFieldId) : FieldError()
}
// warnings
sealed class Warning : AddCustomTokenAction() {
data class Add(val warnings: Set<AddCustomTokenError.Warning>) : Warning()
data class Remove(val warnings: Set<AddCustomTokenError.Warning>) : Warning()
data class Replace(
val remove: Set<AddCustomTokenError.Warning>,
val add: Set<AddCustomTokenError.Warning>,
) : Warning()
}
// To change the screenState
sealed class Screen : AddCustomTokenAction() {
data class UpdateTokenFields(val pairs: List<Pair<FieldId, ViewStates.TokenField>>) : Screen()
data class UpdateAddButton(val addButton: ViewStates.AddButton) : Screen()
}
}

View file

@ -0,0 +1,742 @@
package com.tangem.domain.features.addCustomToken.redux
import android.webkit.ValueCallback
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.extensions.guard
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.AddCustomTokenError.Warning.PotentialScamToken
import com.tangem.domain.AddCustomTokenError.Warning.TokenAlreadyAdded
import com.tangem.domain.AddCustomTokenError.Warning.UnsupportedSolanaToken
import com.tangem.domain.DomainDialog
import com.tangem.domain.DomainWrapped
import com.tangem.domain.common.TapWorkarounds.derivationStyle
import com.tangem.domain.common.extensions.canHandleToken
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.domain.common.form.Field
import com.tangem.domain.common.form.Form
import com.tangem.domain.common.form.TokenContractAddressValidator
import com.tangem.domain.common.form.TokenDecimalsValidator
import com.tangem.domain.common.form.TokenNameValidator
import com.tangem.domain.common.form.TokenNetworkValidator
import com.tangem.domain.common.form.TokenSymbolValidator
import com.tangem.domain.features.addCustomToken.AddCustomTokenService
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.ContractAddress
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Decimals
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.DerivationPath
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Name
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Network
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Symbol
import com.tangem.domain.features.addCustomToken.TokenBlockchainField
import com.tangem.domain.features.addCustomToken.TokenDerivationPathField
import com.tangem.domain.features.addCustomToken.TokenField
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.FieldError
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.Init
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnAddCustomTokenClicked
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnCreate
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnDestroy
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnTokenContractAddressChanged
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnTokenDecimalsChanged
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnTokenDerivationPathChanged
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnTokenNameChanged
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnTokenNetworkChanged
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.OnTokenSymbolChanged
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.Screen
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.SetFoundTokenInfo
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.UpdateForm
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.Warning
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState.Companion.createInitialScreenState
import com.tangem.domain.redux.BaseStoreHub
import com.tangem.domain.redux.DomainState
import com.tangem.domain.redux.ReStoreReducer
import com.tangem.domain.redux.domainStore
import com.tangem.domain.redux.extensions.dispatchOnMain
import com.tangem.domain.redux.global.DomainGlobalAction
import com.tangem.domain.redux.global.DomainGlobalState
import com.tangem.utils.coroutines.AppCoroutineDispatcherProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.rekotlin.Action
import timber.log.Timber
/**
[REDACTED_AUTHOR]
*/
@Suppress("LargeClass")
internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomTokenHub") {
private val hubState: AddCustomTokenState
get() = domainStore.state.addCustomTokensState
override fun getReducer(): ReStoreReducer<AddCustomTokenState> = AddCustomTokenReducer(globalState)
override fun getHubState(storeState: DomainState): AddCustomTokenState = hubState
override fun updateStoreState(storeState: DomainState, newHubState: AddCustomTokenState): DomainState {
return storeState.copy(addCustomTokensState = newHubState)
}
@Suppress("ComplexMethod")
override suspend fun handleAction(action: Action, storeState: DomainState, cancel: ValueCallback<Action>) {
if (action !is AddCustomTokenAction) return
when (action) {
is OnCreate -> {
hubState.appSavedCurrencies.guard {
return throwUnAppropriateInitialization("addedTokens")
}
}
is OnDestroy -> cancelAll()
is OnTokenContractAddressChanged -> {
validateContractAddressAndNotify(action.contractAddress.value)
}
is OnTokenNetworkChanged -> {
if (!action.blockchainNetwork.isUserInput) return
validateContractAddressAndNotify(ContractAddress.getFieldValue())
}
is OnTokenDerivationPathChanged -> {
updateAddButton()
}
is OnTokenNameChanged, is OnTokenSymbolChanged, is OnTokenDecimalsChanged -> {
updateAddButton()
}
is OnAddCustomTokenClicked -> {
val state = hubState
val completeData = when {
state.getCustomTokenType() == CustomTokenType.Token && state.networkIsSelected() -> {
state.gatherUserToken()
}
state.getCustomTokenType() == CustomTokenType.Blockchain && state.networkIsSelected() -> {
state.gatherBlockchain()
}
else -> null
}
if (completeData == null) {
// normally it can't be, because the AddButton must be blocked
} else {
hubScope.launch(Dispatchers.Main) {
state.onTokenAddCallback?.invoke(completeData)
}
}
}
else -> {}
}
}
private suspend fun validateContractAddressAndNotify(contractAddress: String) {
val error = ContractAddress.validateValue(contractAddress)
if (Network.isFilled()) {
when (error) {
null -> {
// valid contract address
ContractAddress.removeError()
findTokenAndUpdateFields(contractAddress)
}
AddCustomTokenError.InvalidContractAddress -> {
ContractAddress.addError(error)
enableDisableTokenDetailFields(hubState.tokensAnyFieldsIsFilled())
}
AddCustomTokenError.FieldIsEmpty -> {
ContractAddress.removeError()
clearTokenDetailsFields()
disableTokenDetailFields()
}
else -> {}
}
} else {
// is default selection (Blockchain.Unknown)
when (error) {
null -> {
// Blockchain.Unknown has always valid contract address
ContractAddress.removeError()
findTokenAndUpdateFields(contractAddress)
}
else -> {
ContractAddress.removeError()
clearTokenDetailsFields()
disableTokenDetailFields()
}
}
}
updateDerivationPath(Network.getFieldValue())
updateWarnings()
updateAddButton()
}
private suspend fun findTokenAndUpdateFields(contractAddress: String) {
val foundTokens = requestInfoAboutToken(contractAddress)
if (foundTokens.isEmpty()) {
// token not found - it's completely custom
dispatchOnMain(SetFoundTokenInfo(null))
enableTokenDetailFields()
return
}
// foundToken - contains all info about the token
val foundToken = foundTokens[0]
dispatchOnMain(SetFoundTokenInfo(foundToken))
when {
foundToken.networks.isEmpty() -> {
Timber.e("Unexpected state -> throw to FB")
}
foundToken.networks.size == 1 -> {
// token with single contract address
val singleTokenContract = foundToken.networks[0]
fillTokenFields(foundToken, singleTokenContract)
disableTokenDetailFields()
}
else -> {
val dialog = DomainDialog.SelectTokenDialog(
items = foundToken.networks,
networkIdConverter = { networkId ->
val blockchain = Blockchain.fromNetworkId(networkId)
if (blockchain == null || blockchain == Blockchain.Unknown) {
throw AddCustomTokenError.SelectTokeNetworkError(networkId)
}
hubState.blockchainToName(blockchain) ?: ""
},
onSelect = { selectedContract ->
hubScope.launch {
// find how to connect to the upper coroutineContext and dispatch through them
fillTokenFields(foundToken, selectedContract)
disableTokenDetailFields()
}
},
)
dispatchOnMain(DomainGlobalAction.ShowDialog(dialog))
}
}
}
private suspend fun updateDerivationPath(blockchainNetwork: Blockchain) {
val state = hubState
val derivationIsSupportedByNetwork = blockchainNetwork.isEvm() || blockchainNetwork == Blockchain.Unknown
if (DerivationPath.isFilled() && !derivationIsSupportedByNetwork) {
// reset to default
val derivationField = DerivationPath.getField<TokenDerivationPathField>()
derivationField.data = derivationField.data.copy(
value = Blockchain.Unknown,
isUserInput = false,
)
state.setField(derivationField)
dispatchOnMain(UpdateForm(hubState))
}
if (state.screenState.derivationPath.isEnabled != derivationIsSupportedByNetwork) {
val action = Screen.UpdateTokenFields(
listOf(
DerivationPath to state.screenState.derivationPath.copy(
isEnabled = derivationIsSupportedByNetwork,
),
),
)
dispatchOnMain(action)
}
}
private suspend fun updateWarnings() {
val state = hubState
val warningsAdd = mutableSetOf<AddCustomTokenError.Warning>()
val warningsRemove = mutableSetOf<AddCustomTokenError.Warning>()
val tokenIsSupported = tokenIsSupported(Network.getFieldValue())
val alreadyAdded = isPersistIntoAppSavedTokensList()
when (state.getCustomTokenType()) {
CustomTokenType.Blockchain -> {
warningsRemove.add(UnsupportedSolanaToken)
if (alreadyAdded) {
warningsAdd.add(TokenAlreadyAdded)
} else {
warningsRemove.add(TokenAlreadyAdded)
}
if (state.derivationPathIsSelected()) {
warningsAdd.add(PotentialScamToken)
} else {
warningsRemove.add(PotentialScamToken)
}
}
CustomTokenType.Token -> {
if (tokenIsSupported) {
warningsRemove.add(UnsupportedSolanaToken)
} else {
val error = ContractAddress.validateValue(ContractAddress.getFieldValue())
when (error) {
AddCustomTokenError.FieldIsEmpty -> warningsRemove.add(UnsupportedSolanaToken)
else -> {
warningsAdd.add(UnsupportedSolanaToken)
}
}
}
if (isPersistIntoAppSavedTokensList()) {
warningsAdd.add(TokenAlreadyAdded)
} else {
warningsRemove.add(TokenAlreadyAdded)
}
if (state.foundToken == null) {
if (state.tokensAnyFieldsIsFilled()) {
warningsAdd.add(PotentialScamToken)
} else {
warningsRemove.add(PotentialScamToken)
}
} else {
if (state.foundToken.active) {
warningsRemove.add(PotentialScamToken)
} else {
warningsAdd.add(PotentialScamToken)
}
}
}
}
dispatchOnMain(
Warning.Replace(
remove = warningsRemove,
add = warningsAdd,
),
)
}
private suspend fun updateAddButton() {
if (isPersistIntoAppSavedTokensList()) {
TokenAlreadyAdded.add()
disableAddButton()
return
} else {
TokenAlreadyAdded.remove()
}
val state = hubState
when {
// token
state.tokensFieldsIsFilled() && state.networkIsSelected() -> {
val error = ContractAddress.validateValue(ContractAddress.getFieldValue<String>())
val tokenIsSupported = tokenIsSupported(Network.getFieldValue())
enableDisableAddButton(tokenIsSupported && error == null)
}
// token
state.tokensAnyFieldsIsFilled() -> {
disableAddButton()
}
// blockchain
else -> {
if (state.networkIsSelected()) {
val alreadyAdded = isBlockchainPersistIntoAppSavedTokensList()
if (alreadyAdded) {
disableAddButton()
} else {
enableAddButton()
}
} else {
disableAddButton()
}
}
}
}
@Suppress("MagicNumber")
private suspend fun requestInfoAboutToken(contractAddress: String): List<CoinsResponse.Coin> {
val tangemTechServiceManager = requireNotNull(hubState.tangemTechServiceManager)
dispatchOnMain(Screen.UpdateTokenFields(listOf(ContractAddress to ViewStates.TokenField(isLoading = true))))
val field = hubState.getField<TokenBlockchainField>(Network)
val selectedNetworkId: String? = field.data.value.let {
if (it == Blockchain.Unknown) null else it
}?.toNetworkId()
// simulate loading effect. It would be better if the delay would only run if tokenManager.checkAddress()
// got the result faster than 500ms and the delay would only be the difference between them.
delay(500)
val result = tangemTechServiceManager.findToken(contractAddress, selectedNetworkId)
dispatchOnMain(Screen.UpdateTokenFields(listOf(ContractAddress to ViewStates.TokenField(isLoading = false))))
return result
}
/**
* These are helper functions.
*/
private fun isPersistIntoAppSavedTokensList(): Boolean = when (hubState.getCustomTokenType()) {
CustomTokenType.Blockchain -> isBlockchainPersistIntoAppSavedTokensList()
CustomTokenType.Token -> isTokenPersistIntoAppSavedTokensList()
}
private fun isTokenPersistIntoAppSavedTokensList(
tokenId: String? = hubState.foundToken?.id,
tokenContractAddress: String = ContractAddress.getFieldValue(),
tokenNetworkId: String = Network.getFieldValue<Blockchain>().toNetworkId(),
selectedDerivation: Blockchain = DerivationPath.getFieldValue(),
): Boolean {
val savedCurrencies = hubState.appSavedCurrencies ?: return false
val derivationPath = getDerivationPathFromSelectedBlockchain(selectedDerivation)
savedCurrencies.forEach { wrappedCurrency ->
when (wrappedCurrency) {
is DomainWrapped.Currency.Blockchain -> {}
is DomainWrapped.Currency.Token -> {
val sameId = tokenId == wrappedCurrency.token.id
val sameAddress = tokenContractAddress == wrappedCurrency.token.contractAddress
val sameBlockchain = Blockchain.fromNetworkId(tokenNetworkId) == wrappedCurrency.blockchain
val sameDerivationPath = derivationPath?.rawPath == wrappedCurrency.derivationPath
@Suppress("ComplexCondition")
if (sameId && sameAddress && sameBlockchain && sameDerivationPath) {
return true
}
}
}
}
return false
}
private fun isBlockchainPersistIntoAppSavedTokensList(
selectedNetwork: Blockchain = Network.getFieldValue(),
selectedDerivation: Blockchain = DerivationPath.getFieldValue(),
): Boolean {
val state = hubState
val savedCurrencies = state.appSavedCurrencies ?: return false
val derivationPath = getDerivationPathFromSelectedBlockchain(selectedDerivation)
savedCurrencies.forEach { wrappedCurrency ->
when (wrappedCurrency) {
is DomainWrapped.Currency.Blockchain -> {
val isSameBlockchain = selectedNetwork == wrappedCurrency.blockchain
val isSameDerivationPath = derivationPath?.rawPath == wrappedCurrency.derivationPath
if (isSameBlockchain && isSameDerivationPath) return true
}
is DomainWrapped.Currency.Token -> {}
}
}
return false
}
private fun getDerivationPathFromSelectedBlockchain(
selectedDerivationBlockchain: Blockchain,
): com.tangem.crypto.hdWallet.DerivationPath? = AddCustomTokenState.getDerivationPath(
mainNetwork = Network.getFieldValue(),
derivationNetwork = selectedDerivationBlockchain,
derivationStyle = hubState.cardDerivationStyle,
)
private suspend fun fillTokenFields(token: CoinsResponse.Coin, coinNetwork: CoinsResponse.Coin.Network) {
val blockchain = Blockchain.fromNetworkId(coinNetwork.networkId) ?: Blockchain.Unknown
Network.setFieldValue(Field.Data(blockchain, false))
Name.setFieldValue(Field.Data(token.name, false))
Symbol.setFieldValue(Field.Data(token.symbol, false))
Decimals.setFieldValue(Field.Data(coinNetwork.decimalCount.toString(), false))
dispatchOnMain(UpdateForm(hubState))
}
private suspend fun clearTokenDetailsFields() {
Name.setFieldValue(Field.Data("", false))
Symbol.setFieldValue(Field.Data("", false))
Decimals.setFieldValue(Field.Data("", false))
dispatchOnMain(UpdateForm(hubState))
}
private suspend fun enableTokenDetailFields() {
enableDisableTokenDetailFields(true)
}
private suspend fun disableTokenDetailFields() {
enableDisableTokenDetailFields(false)
}
private suspend fun enableDisableTokenDetailFields(isEnabled: Boolean = true) {
val state = hubState
val action = Screen.UpdateTokenFields(
listOf(
Name to state.screenState.name.copy(isEnabled = isEnabled),
Symbol to state.screenState.symbol.copy(isEnabled = isEnabled),
Decimals to state.screenState.decimals.copy(isEnabled = isEnabled),
),
)
dispatchOnMain(action)
}
private suspend fun enableAddButton() {
enableDisableAddButton(true)
}
private suspend fun disableAddButton() {
enableDisableAddButton(false)
}
private suspend fun enableDisableAddButton(isEnabled: Boolean) {
dispatchOnMain(Screen.UpdateAddButton(ViewStates.AddButton(isEnabled)))
}
private fun tokenIsSupported(blockchain: Blockchain): Boolean = when (blockchain) {
Blockchain.Unknown -> true
else -> globalState.scanResponse?.card?.canHandleToken(blockchain) ?: false
}
@Throws
private fun throwUnAppropriateInitialization(objName: String) {
throw AddCustomTokenError.UnAppropriateInitialization(
"AddCustomTokenHub",
"$objName must be not NULL",
)
}
private suspend fun CustomTokenFieldId.addError(error: AddCustomTokenError) {
dispatchOnMain(FieldError.Add(this, error))
}
private suspend fun CustomTokenFieldId.removeError() {
dispatchOnMain(FieldError.Remove(this))
}
private inline fun <reified T> CustomTokenFieldId.getField(): T {
val state = hubState
val value = when (this) {
ContractAddress -> state.getField<TokenField>(this)
Network -> state.getField<TokenBlockchainField>(this)
Name -> state.getField<TokenField>(this)
Symbol -> state.getField<TokenField>(this)
Decimals -> state.getField<TokenField>(this)
DerivationPath -> state.getField<TokenDerivationPathField>(this)
}
return value as T
}
private inline fun <reified T> CustomTokenFieldId.getFieldValue(): T {
val value = when (this) {
ContractAddress -> getField<TokenField>().data.value
Network -> getField<TokenBlockchainField>().data.value
Name -> getField<TokenField>().data.value
Symbol -> getField<TokenField>().data.value
Decimals -> getField<TokenField>().data.value
DerivationPath -> getField<TokenDerivationPathField>().data.value
}
return value as T
}
private fun CustomTokenFieldId.setFieldValue(fieldData: Field.Data<*>) {
when (this) {
ContractAddress -> getField<TokenField>().data = fieldData as Field.Data<String>
Network -> getField<TokenBlockchainField>().data = fieldData as Field.Data<Blockchain>
Name -> getField<TokenField>().data = fieldData as Field.Data<String>
Symbol -> getField<TokenField>().data = fieldData as Field.Data<String>
Decimals -> getField<TokenField>().data = fieldData as Field.Data<String>
DerivationPath -> getField<TokenDerivationPathField>().data = fieldData as Field.Data<Blockchain>
}
}
private fun CustomTokenFieldId.validateValue(value: Any): AddCustomTokenError? {
val state = hubState
val contractAddressValidator: TokenContractAddressValidator = state.getValidator(ContractAddress)
val nameValidator: TokenNameValidator = state.getValidator(Name)
val symbolValidator: TokenSymbolValidator = state.getValidator(Symbol)
val decimalsValidator: TokenDecimalsValidator = state.getValidator(Decimals)
val networkValidator: TokenNetworkValidator = state.getValidator(Network)
return when (this) {
ContractAddress -> {
contractAddressValidator.nextValidationFor(Network.getFieldValue())
contractAddressValidator.validate(value as String)
}
Network, DerivationPath -> networkValidator.validate(value as Blockchain)
Name -> nameValidator.validate(value as String)
Symbol -> symbolValidator.validate(value as String)
Decimals -> decimalsValidator.validate(value as String)
}
}
private fun CustomTokenFieldId.isFilled(): Boolean {
return when (this) {
ContractAddress -> getFieldValue<String>().isNotEmpty()
Network -> getFieldValue<Blockchain>() != Blockchain.Unknown
Name -> getFieldValue<String>().isNotEmpty()
Symbol -> getFieldValue<String>().isNotEmpty()
Decimals -> getFieldValue<String>().isNotEmpty()
DerivationPath -> getFieldValue<Blockchain>() != Blockchain.Unknown
}
}
private suspend fun AddCustomTokenError.Warning.add() {
dispatchOnMain(Warning.Add(setOf(this)))
}
private suspend fun AddCustomTokenError.Warning.remove() {
dispatchOnMain(Warning.Remove(setOf(this)))
}
}
@Suppress("ComplexMethod")
private class AddCustomTokenReducer(
private val globalState: DomainGlobalState,
) : ReStoreReducer<AddCustomTokenState> {
@Suppress("LongMethod")
override fun reduceAction(action: Action, state: AddCustomTokenState): AddCustomTokenState {
return when (action) {
is Init.SetAddedCurrencies -> {
state.copy(appSavedCurrencies = action.addedCurrencies)
}
is Init.SetOnAddTokenCallback -> {
state.copy(onTokenAddCallback = action.callback)
}
is OnCreate -> {
val card = requireNotNull(globalState.scanResponse?.card)
val supportedTokenNetworkIds = card.supportedBlockchains()
.filter { it.canHandleTokens() }
.map { it.toNetworkId() }
val tangemTechServiceManager = AddCustomTokenService(
tangemTechApi = globalState.networkServices.tangemTechService.api,
dispatchers = AppCoroutineDispatcherProvider(),
supportedTokenNetworkIds = supportedTokenNetworkIds,
)
val form = Form(AddCustomTokenState.createFormFields(card, CustomTokenType.Blockchain))
state.copy(
cardDerivationStyle = card.derivationStyle,
form = form,
tangemTechServiceManager = tangemTechServiceManager,
screenState = createInitialScreenState(card.settings.isHDWalletAllowed),
)
}
is OnDestroy -> {
val card = requireNotNull(globalState.scanResponse?.card)
state.reset(card)
}
is UpdateForm -> {
updateFormState(action.state)
}
is OnTokenContractAddressChanged -> {
val field: TokenField = state.getField(ContractAddress)
field.data = action.contractAddress
updateFormState(state)
}
is OnTokenNetworkChanged -> {
val field: TokenBlockchainField = state.getField(Network)
field.data = action.blockchainNetwork
updateFormState(state)
}
is OnTokenNameChanged -> {
val field: TokenField = state.getField(Name)
field.data = action.tokenName
updateFormState(state)
}
is OnTokenSymbolChanged -> {
val field: TokenField = state.getField(Symbol)
field.data = action.tokenSymbol
updateFormState(state)
}
is OnTokenDecimalsChanged -> {
val field: TokenField = state.getField(Decimals)
field.data = action.tokenDecimals
updateFormState(state)
}
is OnTokenDerivationPathChanged -> {
val field: TokenDerivationPathField = state.getField(DerivationPath)
field.data = action.blockchainDerivationPath
updateFormState(state)
}
is FieldError.Add -> {
val newMap = state.formErrors.toMutableMap().apply { this[action.id] = action.error }
state.copy(formErrors = newMap)
}
is FieldError.Remove -> {
val newMap = state.formErrors.toMutableMap().apply { remove(action.id) }
state.copy(formErrors = newMap)
}
is SetFoundTokenInfo -> {
state.copy(foundToken = action.foundToken)
}
is Warning.Add -> {
val newList = state.warnings.toMutableSet().apply { addAll(action.warnings) }
state.copy(warnings = newList.toSet())
}
is Warning.Remove -> {
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)
}
}
DerivationPath -> {
if (state.screenState.derivationPath == it.second) {
newScreenState
} else {
newScreenState.copy(derivationPath = 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: AddCustomTokenState): AddCustomTokenState {
return state.copy(form = Form(state.form.fieldList))
}
}

View file

@ -0,0 +1,327 @@
package com.tangem.domain.features.addCustomToken.redux
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.DerivationStyle
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.DomainWrapped
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.common.extensions.isSupportedInApp
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.domain.common.extensions.supportedTokens
import com.tangem.domain.common.form.CustomTokenValidator
import com.tangem.domain.common.form.DataField
import com.tangem.domain.common.form.FieldDataConverter
import com.tangem.domain.common.form.FieldId
import com.tangem.domain.common.form.FieldToJsonConverter
import com.tangem.domain.common.form.Form
import com.tangem.domain.common.form.StringIsEmptyValidator
import com.tangem.domain.common.form.StringIsNotEmptyValidator
import com.tangem.domain.common.form.TokenContractAddressValidator
import com.tangem.domain.common.form.TokenDecimalsValidator
import com.tangem.domain.common.form.TokenNameValidator
import com.tangem.domain.common.form.TokenNetworkValidator
import com.tangem.domain.common.form.TokenSymbolValidator
import com.tangem.domain.features.addCustomToken.AddCustomTokenService
import com.tangem.domain.features.addCustomToken.CustomCurrency
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.ContractAddress
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Decimals
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.DerivationPath
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Name
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Network
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Symbol
import com.tangem.domain.features.addCustomToken.TokenBlockchainField
import com.tangem.domain.features.addCustomToken.TokenDerivationPathField
import com.tangem.domain.features.addCustomToken.TokenField
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.redux.DomainState
import com.tangem.domain.redux.state.StringActionStateConverter
import org.rekotlin.Action
import org.rekotlin.StateType
data class AddCustomTokenState(
val appSavedCurrencies: List<DomainWrapped.Currency>? = null,
val onTokenAddCallback: ((CustomCurrency) -> Unit)? = null,
val cardDerivationStyle: DerivationStyle? = null,
val form: Form = Form(listOf()),
val formValidators: Map<CustomTokenFieldId, CustomTokenValidator<out Any>> = createFormValidators(),
val formErrors: Map<CustomTokenFieldId, AddCustomTokenError> = emptyMap(),
val foundToken: CoinsResponse.Coin? = null,
val warnings: Set<AddCustomTokenError.Warning> = emptySet(),
val screenState: ScreenState = createInitialScreenState(),
val tangemTechServiceManager: AddCustomTokenService? = null,
) : StateType {
inline fun <reified T> getField(id: FieldId): T = form.getField(id) as T
fun setField(field: DataField<*>) {
form.setField(field)
}
inline fun <reified T> getValidator(id: FieldId): T = formValidators[id] as T
fun getError(id: FieldId): AddCustomTokenError? = formErrors[id]
fun hasError(id: FieldId): Boolean = formErrors[id] != null
inline fun <reified T> visitDataConverter(converter: FieldDataConverter<T>): T {
form.visitDataConverter(converter)
return converter.getConvertedData()
}
fun blockchainToName(blockchain: Blockchain, isDerivationPath: Boolean = false): String? {
return when {
isDerivationPath -> blockchain.derivationPath(DerivationStyle.LEGACY)?.rawPath
else -> {
when (blockchain) {
Blockchain.Unknown -> null
else -> blockchain.fullName
}
}
}
}
// except network
fun tokensFieldsIsFilled(): Boolean {
val idsToCheck = listOf(ContractAddress, Name, Symbol, Decimals)
val fieldsToCheck = form.fieldList.filter { idsToCheck.contains(it.id) }
val validator = StringIsNotEmptyValidator()
fieldsToCheck.forEach { field ->
val error = validator.validate(field.data.value?.toString())
if (error != null) return false
}
return true
}
// except network
fun tokensAnyFieldsIsFilled(): Boolean {
val idsToCheck = listOf(ContractAddress, Name, Symbol, Decimals)
val fieldsToCheck = form.fieldList.filter { idsToCheck.contains(it.id) }
val validator = StringIsEmptyValidator()
val errorsList = fieldsToCheck.mapNotNull { field ->
validator.validate(field.data.value?.toString())
}
return errorsList.isNotEmpty()
}
fun networkIsSelected(): Boolean {
val network = getField<TokenBlockchainField>(Network)
return network.data.value != Blockchain.Unknown
}
fun derivationPathIsSelected(): Boolean {
val network = getField<TokenDerivationPathField>(DerivationPath)
return network.data.value != Blockchain.Unknown
}
fun getCustomTokenType(): CustomTokenType = when {
tokensAnyFieldsIsFilled() || tokensFieldsIsFilled() -> CustomTokenType.Token
else -> CustomTokenType.Blockchain
}
fun gatherUserToken(): CustomCurrency.CustomToken? = try {
getToken()
} catch (ex: Exception) {
null
}
fun gatherBlockchain(): CustomCurrency.CustomBlockchain? = try {
getBlockchain()
} catch (ex: Exception) {
null
}
fun reset(card: CardDTO): AddCustomTokenState {
return this.copy(
appSavedCurrencies = null,
onTokenAddCallback = null,
cardDerivationStyle = null,
form = Form(createFormFields(card, CustomTokenType.Blockchain)),
formErrors = emptyMap(),
foundToken = null,
warnings = emptySet(),
screenState = createInitialScreenState(card.settings.isHDWalletAllowed),
tangemTechServiceManager = null,
)
}
private fun getToken(): CustomCurrency.CustomToken {
return CustomCurrency.CustomToken.Converter(foundToken?.id, cardDerivationStyle)
.apply { visitDataConverter(this) }
.getConvertedData()
}
private fun getBlockchain(): CustomCurrency.CustomBlockchain {
return CustomCurrency.CustomBlockchain.Converter(cardDerivationStyle)
.apply { visitDataConverter(this) }
.getConvertedData()
}
fun getNetworks(card: CardDTO, type: CustomTokenType): List<Blockchain> {
return getNetworksList(card, type)
}
companion object {
/**
* If an user select derivation path (derivationNetwork) as Blockchain.Unknown,
* then we should use a blockchain from the mainNetwork to determine a DerivationPath
*/
internal fun getDerivationPath(
mainNetwork: Blockchain,
derivationNetwork: Blockchain,
derivationStyle: DerivationStyle?,
): com.tangem.crypto.hdWallet.DerivationPath? {
// If we allow user to select derivations, we need to provide different derivations
// (Legacy style derivations).
// But the mainNetwork derivation depends on whether a user has a card
// with legacy derivations or new style derivations.
val derivationStyleToUse = if (derivationNetwork == Blockchain.Unknown) {
derivationStyle
} else {
DerivationStyle.LEGACY
}
return when (derivationNetwork) {
Blockchain.Unknown -> mainNetwork
else -> derivationNetwork
}.derivationPath(derivationStyleToUse)
}
internal fun createFormFields(card: CardDTO, type: CustomTokenType): List<DataField<*>> {
return listOf(
TokenField(ContractAddress),
TokenBlockchainField(Network, getNetworksList(card, type)),
TokenField(Name),
TokenField(Symbol),
TokenField(Decimals),
TokenDerivationPathField(DerivationPath, getSupportedDerivations(card)),
)
}
/**
* Serves to determine the networks (blockchains & tokens) that can be selected by Form.Networks.
* Blockchain.Unknown - is the default selection
*/
private fun getNetworksList(card: CardDTO, type: CustomTokenType): List<Blockchain> {
val evmBlockchains = Blockchain.values()
.filter { it.isEvm() }
.filter { card.isTestCard == it.isTestnet() }
val additionalBlockchains = listOf(
Blockchain.Binance,
Blockchain.BinanceTestnet,
Blockchain.Solana,
Blockchain.SolanaTestnet,
Blockchain.Tron,
Blockchain.TronTestnet,
)
val supportedByCard = when (type) {
CustomTokenType.Blockchain -> card.supportedBlockchains()
CustomTokenType.Token -> card.supportedTokens()
}
val typedNetworksList = (evmBlockchains + additionalBlockchains)
.filter { supportedByCard.contains(it) }
.toMutableList()
val default = Blockchain.Unknown
typedNetworksList.add(0, default)
return typedNetworksList.sortByName()
}
private fun createFormValidators(): Map<CustomTokenFieldId, CustomTokenValidator<out Any>> {
return mapOf(
ContractAddress to TokenContractAddressValidator(),
Network to TokenNetworkValidator(),
Name to TokenNameValidator(),
Symbol to TokenSymbolValidator(),
Decimals to TokenDecimalsValidator(),
)
}
private fun getSupportedDerivations(card: CardDTO): List<Blockchain> {
val evmBlockchains = Blockchain.values()
.filter { card.isTestCard == it.isTestnet() && it.isEvm() }
.filter { it.isSupportedInApp() }
return (listOf(Blockchain.Unknown) + evmBlockchains).sortByName()
}
internal fun createInitialScreenState(showDerivationPathField: Boolean = false): ScreenState {
return ScreenState(
contractAddressField = ViewStates.TokenField(),
network = ViewStates.TokenField(),
name = ViewStates.TokenField(isEnabled = false),
symbol = ViewStates.TokenField(isEnabled = false),
decimals = ViewStates.TokenField(isEnabled = false),
derivationPath = ViewStates.TokenField(isVisible = showDerivationPathField),
addButton = ViewStates.AddButton(isEnabled = false),
)
}
}
class Converter : StringActionStateConverter<DomainState> {
private val jsonConverter: MoshiJsonConverter = MoshiJsonConverter.INSTANCE
private var builder: StringBuilder = StringBuilder()
override fun convert(action: Action, stateHolder: DomainState): String? {
val action = action as? AddCustomTokenAction ?: return null
val state = stateHolder.addCustomTokensState
val fieldConverter =
FieldToJsonConverter(
listOf(
ContractAddress,
Network,
Name,
Symbol,
Decimals,
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
}
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")
}
}
}
private fun List<Blockchain>.sortByName(): List<Blockchain> = this.sortedBy { it.fullName }
enum class CustomTokenType {
Token, Blockchain
}

View file

@ -0,0 +1,27 @@
package com.tangem.domain.features.addCustomToken.redux
/**
[REDACTED_AUTHOR]
*/
// 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()
}

View file

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

View file

@ -0,0 +1,43 @@
package com.tangem.domain.redux
import com.tangem.domain.DomainLayer
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenHub
import com.tangem.domain.redux.global.DomainGlobalHub
import org.rekotlin.Action
import org.rekotlin.Store
/**
[REDACTED_AUTHOR]
*/
private val RE_STORE_HUBS: List<ReStoreHub<DomainState, *>> = listOf(
DomainGlobalHub(),
AddCustomTokenHub(),
)
val domainStore = Store(
state = DomainState(),
middleware = RE_STORE_HUBS.map { it.getMiddleware() },
reducer = { action, state -> reduce(action, state) },
)
private fun reduce(action: Action, domainState: DomainState?): DomainState {
requireNotNull(domainState)
// we can examine the store state after each change by reducer
var assembleReducedDomainState: DomainState = domainState
val reducedStatesByAction = mutableListOf<Pair<Action, DomainState>>()
RE_STORE_HUBS.forEach {
val reducedState = it.reduce(action, assembleReducedDomainState)
assembleReducedDomainState = if (reducedState != assembleReducedDomainState) {
reducedStatesByAction.add(action to assembleReducedDomainState)
reducedState
} else {
assembleReducedDomainState
}
}
DomainLayer.actionStateLogger.log(reducedStatesByAction)
return assembleReducedDomainState
}

View file

@ -0,0 +1,112 @@
package com.tangem.domain.redux
import android.webkit.ValueCallback
import com.tangem.domain.redux.global.DomainGlobalState
import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
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 <StoreState> or the <State>, because this can lead to destabilization of
* a state behavior.
* All ReStoreHub's must be marked as internal
*/
internal interface ReStoreHub<StoreState, State> {
fun getMiddleware(): Middleware<StoreState>
fun reduce(action: Action, domainState: StoreState): StoreState
}
internal interface ReStoreReducer<State> {
fun reduceAction(action: Action, state: State): State
}
/**
* 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
* Do not implement other states like as DomainGlobalState. Because it can dilute the responsibility of
* states.
* @param name - name of the Hub
* @param dispatcher - main coroutine dispatcher for actions
* @property globalState - state witch produce accessibility to global variables
*/
internal abstract class BaseStoreHub<State>(
private val name: String,
private val dispatcher: CoroutineDispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher(),
) : ReStoreHub<DomainState, State> {
val globalState: DomainGlobalState
get() = domainStore.state.globalState
val hubScope = CoroutineScope(
Job() + dispatcher + CoroutineName(name) + FeatureCoroutineExceptionHandler.create(name),
)
private val actionsAndJobs = mutableMapOf<Action, Job>()
override fun getMiddleware(): Middleware<DomainState> {
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 it. If the action hasn't updated the hubState, then it doesn't need to update
* storeState
*/
override fun reduce(action: Action, domainState: DomainState): DomainState {
val hubOldState = getHubState(domainState)
val hubNewState = getReducer().reduceAction(action, hubOldState)
return if (hubOldState === hubNewState) {
domainState
} else {
updateStoreState(domainState, hubNewState)
}
}
protected fun cancelAll() {
actionsAndJobs.forEach { (_, job) -> job.cancel() }
}
protected abstract suspend fun handleAction(action: Action, storeState: DomainState, cancel: ValueCallback<Action>)
protected abstract fun getReducer(): ReStoreReducer<State>
protected abstract fun getHubState(storeState: DomainState): State
protected abstract fun updateStoreState(storeState: DomainState, newHubState: State): DomainState
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.redux.extensions
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.domain.redux.domainStore
import org.rekotlin.Action
/**
[REDACTED_AUTHOR]
*/
internal suspend inline fun dispatchOnMain(vararg actions: Action) {
withMainContext { actions.forEach { domainStore.dispatch(it) } }
}

View file

@ -0,0 +1,14 @@
package com.tangem.domain.redux.global
import com.tangem.domain.DomainDialog
import com.tangem.domain.models.scan.ScanResponse
import org.rekotlin.Action
/**
[REDACTED_AUTHOR]
*/
// TODO: refactoring: is alias for the GlobalAction
sealed class DomainGlobalAction : Action {
data class SaveScanNoteResponse(val scanResponse: ScanResponse) : DomainGlobalAction()
data class ShowDialog(val stateDialog: DomainDialog?) : DomainGlobalAction()
}

View file

@ -0,0 +1,55 @@
package com.tangem.domain.redux.global
import android.webkit.ValueCallback
import com.tangem.common.extensions.toHexString
import com.tangem.datasource.utils.RequestHeader
import com.tangem.domain.redux.BaseStoreHub
import com.tangem.domain.redux.DomainState
import com.tangem.domain.redux.ReStoreReducer
import com.tangem.lib.auth.AuthProvider
import org.rekotlin.Action
/**
[REDACTED_AUTHOR]
*/
// TODO: refactoring: is alias for the GlobalMiddleware and the GlobalReducer
internal class DomainGlobalHub : BaseStoreHub<DomainGlobalState>("DomainGlobalHub") {
override fun getHubState(storeState: DomainState): DomainGlobalState {
return storeState.globalState
}
override fun updateStoreState(storeState: DomainState, newHubState: DomainGlobalState): DomainState {
return storeState.copy(globalState = newHubState)
}
override suspend fun handleAction(action: Action, storeState: DomainState, cancel: ValueCallback<Action>) {
if (action !is DomainGlobalAction) return
}
override fun getReducer(): ReStoreReducer<DomainGlobalState> = DomainGlobalReducer()
}
private class DomainGlobalReducer : ReStoreReducer<DomainGlobalState> {
override fun reduceAction(action: Action, state: DomainGlobalState): DomainGlobalState {
return when (action) {
is DomainGlobalAction.SaveScanNoteResponse -> {
val card = action.scanResponse.card
state.networkServices.tangemTechService.addAuthenticationHeader(
RequestHeader.AuthenticationHeader(
object : AuthProvider {
override fun getCardPublicKey(): String = card.cardPublicKey.toHexString()
override fun getCardId(): String = card.cardId
},
),
)
state.copy(scanResponse = action.scanResponse)
}
is DomainGlobalAction.ShowDialog -> {
state.copy(dialog = action.stateDialog)
}
else -> state
}
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.domain.redux.global
import com.tangem.datasource.api.paymentology.PaymentologyApiService
import com.tangem.datasource.api.tangemTech.TangemTechService
import com.tangem.domain.DomainDialog
import com.tangem.domain.models.scan.ScanResponse
/**
[REDACTED_AUTHOR]
*/
data class DomainGlobalState(
// there is a part of mirrors from the GlobalState.
// It updates on GlobalAction.SaveScanNoteResponse -> DomainGlobalAction.SaveScanNoteResponse(scanResponse)
val scanResponse: ScanResponse? = null,
//
val networkServices: NetworkServices = NetworkServices(),
val dialog: DomainDialog? = null,
)
data class NetworkServices(
val tangemTechService: TangemTechService = TangemTechService,
val paymentologyService: PaymentologyApiService = PaymentologyApiService,
)

View file

@ -0,0 +1,35 @@
package com.tangem.domain.redux.state
import com.tangem.domain.redux.DomainState
import org.rekotlin.Action
/**
[REDACTED_AUTHOR]
*/
interface StringStateConverter<StateHolder> {
fun convert(stateHolder: StateHolder): String
}
interface StringActionStateConverter<StateHolder> {
fun convert(action: Action, stateHolder: StateHolder): String?
}
class ActionStateConvertersFactory {
private val stateConverters = mutableMapOf<Class<out Action>, StringActionStateConverter<DomainState>>()
fun addConverter(classOfAction: Class<out Action>, converter: StringActionStateConverter<DomainState>) {
stateConverters[classOfAction] = converter
}
fun getConverter(action: Action): StringActionStateConverter<DomainState>? {
val converter = stateConverters.firstNotNullOfOrNull { (classOfAction, converter) ->
if (classOfAction.isAssignableFrom(action::class.java)) {
converter
} else {
null
}
} ?: return null
return converter
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.domain.redux.state
import com.tangem.domain.features.BuildConfig
import com.tangem.domain.redux.DomainState
import org.rekotlin.Action
import timber.log.Timber
/**
[REDACTED_AUTHOR]
* Use it only in debug mode!
*/
internal interface ActionStateLogger {
fun log(reducedSates: List<Pair<Action, DomainState>>)
}
internal class ActionStateLoggerImpl : ActionStateLogger {
val actionStateConvertersFactory = ActionStateConvertersFactory()
override fun log(reducedSates: List<Pair<Action, DomainState>>) {
if (!BuildConfig.LOG_ENABLED) return
logStates(reducedSates)
}
private fun logStates(reducedSates: List<Pair<Action, DomainState>>) {
reducedSates.forEach { (action, domainState) ->
val messageToPrint = actionStateConvertersFactory.getConverter(action)
?.convert(action, domainState)
?: return@forEach
Timber.d(messageToPrint)
}
}
}