Updated on 2026-08-14
This commit is contained in:
parent
330ae73357
commit
e7efe7b76a
4636 changed files with 234864 additions and 63507 deletions
1
data/common/.gitignore
vendored
Normal file
1
data/common/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
35
data/common/build.gradle.kts
Normal file
35
data/common/build.gradle.kts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.common"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/* Core */
|
||||
implementation(projects.core.datasource)
|
||||
|
||||
/* Domain */
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.tokens.models)
|
||||
|
||||
/* Libs - SDK */
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
/* DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
/* Libs - Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.timber)
|
||||
implementation(deps.arrow.core)
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.tangem.data.common.api
|
||||
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.recover
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* A wrapper around the [Raise] interface specific for [ApiResponseError]. It provides utility functions to
|
||||
* operate on [ApiResponse] instances.
|
||||
*
|
||||
* @property raise A [Raise] instance for raising [ApiResponseError].
|
||||
*/
|
||||
@JvmInline
|
||||
value class ApiResponseRaise(
|
||||
private val raise: Raise<ApiResponseError>,
|
||||
) : Raise<ApiResponseError> by raise {
|
||||
|
||||
/**
|
||||
* Binds the given [ApiResponse] to its underlying value or raises an error.
|
||||
*
|
||||
* @return The underlying data of the response if it's successful.
|
||||
*/
|
||||
fun <T : Any> ApiResponse<T>.bind(): T = when (this) {
|
||||
is ApiResponse.Success -> data
|
||||
is ApiResponse.Error -> raise.raise(cause)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to execute an API call safely, providing error handling and a timeout.
|
||||
*
|
||||
* @param T The return type of the API call and the function.
|
||||
* @param timeoutMillis The timeout in milliseconds for the API call. Default is 30 seconds.
|
||||
* @param call The API call block to execute.
|
||||
* @param onError A function to handle errors and return a fallback value of type [T].
|
||||
*
|
||||
* @return The result of the API call or the fallback value provided by [onError] if an error occurs.
|
||||
*/
|
||||
suspend inline fun <T> safeApiCallWithTimeout(
|
||||
timeoutMillis: Duration = with(Duration) { 30.seconds },
|
||||
crossinline call: suspend ApiResponseRaise.() -> T,
|
||||
crossinline onError: suspend (ApiResponseError) -> T,
|
||||
): T = safeApiCall(
|
||||
call = {
|
||||
withTimeoutOrNull(timeoutMillis) { call() }
|
||||
?: raise(ApiResponseError.TimeoutException)
|
||||
},
|
||||
onError = onError,
|
||||
)
|
||||
|
||||
/**
|
||||
* Attempts to execute an API call safely, providing error handling.
|
||||
*
|
||||
* @param T The return type of the API call and the function.
|
||||
* @param call The API call block to execute.
|
||||
* @param onError A function to handle errors and return a fallback value of type [T].
|
||||
*
|
||||
* @return The result of the API call or the fallback value provided by [onError] if an error occurs.
|
||||
*/
|
||||
suspend inline fun <T> safeApiCall(
|
||||
crossinline call: suspend ApiResponseRaise.() -> T,
|
||||
crossinline onError: suspend (ApiResponseError) -> T,
|
||||
): T = recover(
|
||||
block = { call(ApiResponseRaise(raise = this)) },
|
||||
recover = {
|
||||
Timber.e(it, "Unable to perform safe API call")
|
||||
onError(it)
|
||||
},
|
||||
)
|
||||
61
data/common/src/main/kotlin/com/tangem/data/common/cache/CacheRegistry.kt
vendored
Normal file
61
data/common/src/main/kotlin/com/tangem/data/common/cache/CacheRegistry.kt
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package com.tangem.data.common.cache
|
||||
|
||||
import org.joda.time.Duration
|
||||
|
||||
/**
|
||||
* Represents a registry for managing cache.
|
||||
*/
|
||||
interface CacheRegistry {
|
||||
|
||||
/**
|
||||
* Checks whether the cache key is expired.
|
||||
*
|
||||
* @param key cache key.
|
||||
* @return `true` if the cache key is expired, `false` otherwise.
|
||||
*/
|
||||
suspend fun isExpired(key: String): Boolean
|
||||
|
||||
/**
|
||||
* Invalidates the cache key in registry.
|
||||
*
|
||||
* If the key doesn't exist, or it's already invalidated, this method doesn't have any effect.
|
||||
*
|
||||
* @param key cache key.
|
||||
*/
|
||||
suspend fun invalidate(key: String)
|
||||
|
||||
/**
|
||||
* Invalidates cache keys in registry.
|
||||
*
|
||||
* If the key doesn't exist, or it's already invalidated, this method doesn't have any effect.
|
||||
*
|
||||
* @param keys cache keys.
|
||||
*/
|
||||
suspend fun invalidate(keys: Collection<String>)
|
||||
|
||||
/**
|
||||
* Invalidates all cache keys in the registry.
|
||||
*
|
||||
* After the call, the registry doesn't contain any valid keys.
|
||||
*/
|
||||
suspend fun invalidateAll()
|
||||
|
||||
/**
|
||||
* Defines a callback to be invoked when the cache key expires.
|
||||
*
|
||||
* @param key cache key.
|
||||
* @param skipCache if `true`, the callback will be invoked regardless of whether the key has expired or not.
|
||||
* @param expireIn the duration after which the cache key is considered expired.
|
||||
* @param block the block of code to be executed when the cache key expires.
|
||||
*/
|
||||
suspend fun invokeOnExpire(
|
||||
key: String,
|
||||
skipCache: Boolean,
|
||||
expireIn: Duration = Duration.standardMinutes(DEFAULT_CACHE_KEY_EXPIRE_IN_MINUTES),
|
||||
block: suspend () -> Unit,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_CACHE_KEY_EXPIRE_IN_MINUTES = 5L
|
||||
}
|
||||
}
|
||||
82
data/common/src/main/kotlin/com/tangem/data/common/cache/DefaultCacheRegistry.kt
vendored
Normal file
82
data/common/src/main/kotlin/com/tangem/data/common/cache/DefaultCacheRegistry.kt
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package com.tangem.data.common.cache
|
||||
|
||||
import com.tangem.datasource.local.cache.CacheKeysStore
|
||||
import com.tangem.datasource.local.cache.model.CacheKey
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.joda.time.Duration
|
||||
import org.joda.time.LocalDateTime
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
internal class DefaultCacheRegistry(
|
||||
private val cacheKeysStore: CacheKeysStore,
|
||||
) : CacheRegistry {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val mutexes = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
override suspend fun isExpired(key: String): Boolean {
|
||||
val cacheKey = cacheKeysStore.getSyncOrNull(key) ?: return true
|
||||
|
||||
return cacheKey.updatedAt
|
||||
.plus(cacheKey.expiresIn)
|
||||
.isBefore(LocalDateTime.now())
|
||||
}
|
||||
|
||||
override suspend fun invalidate(key: String) {
|
||||
Timber.d("Invalidate the cache key: $key")
|
||||
withContext(NonCancellable) { cacheKeysStore.remove(key) }
|
||||
}
|
||||
|
||||
override suspend fun invalidate(keys: Collection<String>) {
|
||||
Timber.d("Invalidate cache keys: $keys")
|
||||
withContext(NonCancellable) { cacheKeysStore.remove(keys) }
|
||||
}
|
||||
|
||||
override suspend fun invalidateAll() {
|
||||
Timber.d("Invalidate all cache keys")
|
||||
withContext(NonCancellable) { cacheKeysStore.clear() }
|
||||
}
|
||||
|
||||
override suspend fun invokeOnExpire(
|
||||
key: String,
|
||||
skipCache: Boolean,
|
||||
expireIn: Duration,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
// use a separate mutexForKey for each key to avoid multiple calls block() to the same key
|
||||
// also used mutex to safe create mutexForKey, otherwise it can lead to multiple calls for the same key
|
||||
val mutexForKey = mutex.withLock {
|
||||
mutexes.getOrPut(key) { Mutex() }
|
||||
}
|
||||
mutexForKey.withLock {
|
||||
val isExpired = isExpired(key) || skipCache
|
||||
if (!isExpired) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Timber.d("Invoke the action associated with the cache key: $key")
|
||||
|
||||
cacheKeysStore.store(
|
||||
key = CacheKey(
|
||||
id = key,
|
||||
updatedAt = LocalDateTime.now(),
|
||||
expiresIn = expireIn,
|
||||
),
|
||||
)
|
||||
|
||||
block()
|
||||
} catch (e: Throwable) {
|
||||
Timber.e(e, "The action related to the cache key has failed: $key")
|
||||
|
||||
invalidate(key)
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
data/common/src/main/kotlin/com/tangem/data/common/cache/di/CacheRegistryModule.kt
vendored
Normal file
21
data/common/src/main/kotlin/com/tangem/data/common/cache/di/CacheRegistryModule.kt
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package com.tangem.data.common.cache.di
|
||||
|
||||
import com.tangem.data.common.cache.CacheRegistry
|
||||
import com.tangem.data.common.cache.DefaultCacheRegistry
|
||||
import com.tangem.datasource.local.cache.CacheKeysStore
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object CacheRegistryModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCacheRegistry(cacheKeysStore: CacheKeysStore): CacheRegistry {
|
||||
return DefaultCacheRegistry(cacheKeysStore)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
package com.tangem.data.common.currency
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.blockchainsdk.utils.toCoinId
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Network
|
||||
import timber.log.Timber
|
||||
import com.tangem.blockchain.common.Token as SdkToken
|
||||
|
||||
// FIXME: Make internal
|
||||
class CryptoCurrencyFactory(
|
||||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
) {
|
||||
|
||||
@Suppress("LongParameterList") // Yep, it's long
|
||||
fun createToken(
|
||||
network: Network,
|
||||
rawId: CryptoCurrency.RawID?,
|
||||
name: String,
|
||||
symbol: String,
|
||||
decimals: Int,
|
||||
contractAddress: String,
|
||||
): CryptoCurrency.Token {
|
||||
val id = getTokenId(network, rawId, contractAddress)
|
||||
|
||||
return CryptoCurrency.Token(
|
||||
id = id,
|
||||
network = network,
|
||||
name = name,
|
||||
symbol = symbol,
|
||||
decimals = decimals,
|
||||
iconUrl = rawId?.let(::getTokenIconUrlFromDefaultHost),
|
||||
isCustom = isCustomToken(id, network),
|
||||
contractAddress = contractAddress,
|
||||
)
|
||||
}
|
||||
|
||||
fun createToken(
|
||||
sdkToken: SdkToken,
|
||||
blockchain: Blockchain,
|
||||
extraDerivationPath: String?,
|
||||
scanResponse: ScanResponse,
|
||||
): CryptoCurrency.Token? {
|
||||
if (blockchain == Blockchain.Unknown) {
|
||||
Timber.e("Unable to map the SDK token to the domain token with Unknown blockchain")
|
||||
return null
|
||||
}
|
||||
|
||||
val network = getNetwork(blockchain, extraDerivationPath, scanResponse, excludedBlockchains) ?: return null
|
||||
val id = getTokenId(network, sdkToken)
|
||||
|
||||
return CryptoCurrency.Token(
|
||||
id = id,
|
||||
network = network,
|
||||
name = sdkToken.name,
|
||||
symbol = sdkToken.symbol,
|
||||
iconUrl = getTokenIconUrl(blockchain, sdkToken),
|
||||
decimals = sdkToken.decimals,
|
||||
isCustom = isCustomToken(id, network),
|
||||
contractAddress = sdkToken.contractAddress,
|
||||
)
|
||||
}
|
||||
|
||||
fun createCoin(
|
||||
blockchain: Blockchain,
|
||||
extraDerivationPath: String?,
|
||||
scanResponse: ScanResponse,
|
||||
): CryptoCurrency.Coin? {
|
||||
if (blockchain == Blockchain.Unknown) {
|
||||
Timber.e("Unable to map the SDK token to the domain token with Unknown blockchain")
|
||||
return null
|
||||
}
|
||||
val network = getNetwork(blockchain, extraDerivationPath, scanResponse, excludedBlockchains) ?: return null
|
||||
|
||||
return createCoin(network)
|
||||
}
|
||||
|
||||
fun createCoin(networkId: String, extraDerivationPath: String?, scanResponse: ScanResponse): CryptoCurrency.Coin? {
|
||||
val blockchain = Blockchain.fromNetworkId(networkId) ?: Blockchain.Unknown
|
||||
return createCoin(blockchain, extraDerivationPath, scanResponse)
|
||||
}
|
||||
|
||||
fun createCoin(network: Network): CryptoCurrency.Coin {
|
||||
val blockchain = Blockchain.fromId(network.id.value)
|
||||
|
||||
return CryptoCurrency.Coin(
|
||||
id = getCoinId(network, blockchain.toCoinId()),
|
||||
network = network,
|
||||
name = blockchain.getCoinName(),
|
||||
symbol = blockchain.currency,
|
||||
iconUrl = getCoinIconUrl(blockchain),
|
||||
decimals = blockchain.decimals(),
|
||||
isCustom = isCustomCoin(network),
|
||||
)
|
||||
}
|
||||
|
||||
fun createToken(
|
||||
token: Token,
|
||||
networkId: String,
|
||||
extraDerivationPath: String?,
|
||||
scanResponse: ScanResponse,
|
||||
): CryptoCurrency.Token? {
|
||||
val sdkToken = SdkToken(
|
||||
name = token.name,
|
||||
symbol = token.symbol,
|
||||
contractAddress = token.contractAddress,
|
||||
decimals = token.decimals,
|
||||
id = token.id,
|
||||
)
|
||||
val blockchain = Blockchain.fromNetworkId(networkId) ?: Blockchain.Unknown
|
||||
return createToken(
|
||||
sdkToken = sdkToken,
|
||||
blockchain = blockchain,
|
||||
extraDerivationPath = extraDerivationPath,
|
||||
scanResponse = scanResponse,
|
||||
)
|
||||
}
|
||||
|
||||
fun createToken(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token {
|
||||
val sdkToken = SdkToken(
|
||||
name = cryptoCurrency.name,
|
||||
symbol = cryptoCurrency.symbol,
|
||||
contractAddress = cryptoCurrency.contractAddress,
|
||||
decimals = cryptoCurrency.decimals,
|
||||
id = cryptoCurrency.id.rawCurrencyId?.value,
|
||||
)
|
||||
val blockchain = Blockchain.fromNetworkId(cryptoCurrency.network.backendId) ?: Blockchain.Unknown
|
||||
val id = getTokenId(network, sdkToken)
|
||||
return CryptoCurrency.Token(
|
||||
id = id,
|
||||
network = network,
|
||||
name = sdkToken.name,
|
||||
symbol = sdkToken.symbol,
|
||||
iconUrl = getTokenIconUrl(blockchain, sdkToken),
|
||||
decimals = sdkToken.decimals,
|
||||
isCustom = isCustomToken(id, network),
|
||||
contractAddress = sdkToken.contractAddress,
|
||||
)
|
||||
}
|
||||
|
||||
data class Token(
|
||||
val name: String,
|
||||
val symbol: String,
|
||||
val contractAddress: String,
|
||||
val decimals: Int,
|
||||
val id: String? = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
package com.tangem.data.common.currency
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.FeePaidCurrency
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.domain.common.DerivationStyleProvider
|
||||
import com.tangem.domain.common.extensions.canHandleToken
|
||||
import com.tangem.domain.common.util.cardTypesResolver
|
||||
import com.tangem.domain.common.util.derivationStyleProvider
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.tokens.model.Network
|
||||
import timber.log.Timber
|
||||
|
||||
fun getBlockchain(networkId: Network.ID): Blockchain {
|
||||
return Blockchain.fromId(networkId.value)
|
||||
}
|
||||
|
||||
fun getNetwork(
|
||||
blockchain: Blockchain,
|
||||
extraDerivationPath: String?,
|
||||
derivationStyleProvider: DerivationStyleProvider?,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
canHandleTokens: Boolean,
|
||||
): Network? {
|
||||
if (!isBlockchainSupported(blockchain, excludedBlockchains)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Network(
|
||||
id = Network.ID(blockchain.id),
|
||||
backendId = blockchain.toNetworkId(),
|
||||
name = blockchain.fullName,
|
||||
isTestnet = blockchain.isTestnet(),
|
||||
derivationPath = getNetworkDerivationPath(
|
||||
blockchain = blockchain,
|
||||
extraDerivationPath = extraDerivationPath,
|
||||
cardDerivationStyleProvider = derivationStyleProvider,
|
||||
),
|
||||
currencySymbol = blockchain.currency,
|
||||
standardType = getNetworkStandardType(blockchain),
|
||||
hasFiatFeeRate = blockchain.feePaidCurrency() !is FeePaidCurrency.FeeResource,
|
||||
canHandleTokens = canHandleTokens,
|
||||
transactionExtrasType = blockchain.getSupportedTransactionExtras(),
|
||||
)
|
||||
}
|
||||
|
||||
fun getNetwork(
|
||||
networkId: Network.ID,
|
||||
derivationPath: Network.DerivationPath,
|
||||
scanResponse: ScanResponse,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
): Network? {
|
||||
val blockchain = getBlockchain(networkId)
|
||||
if (!isBlockchainSupported(blockchain, excludedBlockchains)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Network(
|
||||
id = networkId,
|
||||
backendId = blockchain.toNetworkId(),
|
||||
name = blockchain.fullName,
|
||||
isTestnet = blockchain.isTestnet(),
|
||||
derivationPath = derivationPath,
|
||||
currencySymbol = blockchain.currency,
|
||||
standardType = getNetworkStandardType(blockchain),
|
||||
hasFiatFeeRate = blockchain.feePaidCurrency() !is FeePaidCurrency.FeeResource,
|
||||
canHandleTokens = scanResponse.card.canHandleToken(
|
||||
blockchain,
|
||||
scanResponse.cardTypesResolver,
|
||||
excludedBlockchains,
|
||||
),
|
||||
transactionExtrasType = blockchain.getSupportedTransactionExtras(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isBlockchainSupported(blockchain: Blockchain, excludedBlockchains: ExcludedBlockchains): Boolean {
|
||||
if (blockchain == Blockchain.Unknown) {
|
||||
Timber.w("Unable to convert Unknown blockchain to the domain network model")
|
||||
return false
|
||||
}
|
||||
if (blockchain in excludedBlockchains) {
|
||||
Timber.w("Unable to convert excluded blockchain to the domain network model")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun getNetwork(
|
||||
blockchain: Blockchain,
|
||||
extraDerivationPath: String?,
|
||||
scanResponse: ScanResponse,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
): Network? {
|
||||
return getNetwork(
|
||||
blockchain = blockchain,
|
||||
extraDerivationPath = extraDerivationPath,
|
||||
derivationStyleProvider = scanResponse.derivationStyleProvider,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
canHandleTokens = scanResponse.card.canHandleToken(
|
||||
blockchain,
|
||||
scanResponse.cardTypesResolver,
|
||||
excludedBlockchains,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun getNetworkDerivationPath(
|
||||
blockchain: Blockchain,
|
||||
extraDerivationPath: String?,
|
||||
cardDerivationStyleProvider: DerivationStyleProvider?,
|
||||
): Network.DerivationPath {
|
||||
if (cardDerivationStyleProvider == null) {
|
||||
return Network.DerivationPath.None
|
||||
}
|
||||
|
||||
val defaultDerivationPath = getDefaultDerivationPath(blockchain, cardDerivationStyleProvider)
|
||||
|
||||
return if (extraDerivationPath.isNullOrBlank()) {
|
||||
if (defaultDerivationPath.isNullOrBlank()) {
|
||||
Network.DerivationPath.None
|
||||
} else {
|
||||
Network.DerivationPath.Card(defaultDerivationPath)
|
||||
}
|
||||
} else {
|
||||
if (extraDerivationPath == defaultDerivationPath) {
|
||||
Network.DerivationPath.Card(defaultDerivationPath)
|
||||
} else {
|
||||
Network.DerivationPath.Custom(extraDerivationPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType {
|
||||
return when (blockchain) {
|
||||
Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.StandardType.ERC20
|
||||
Blockchain.BSC, Blockchain.BSCTestnet -> Network.StandardType.BEP20
|
||||
Blockchain.Binance, Blockchain.BinanceTestnet -> Network.StandardType.BEP2
|
||||
Blockchain.Tron, Blockchain.TronTestnet -> Network.StandardType.TRC20
|
||||
else -> Network.StandardType.Unspecified(blockchain.name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDefaultDerivationPath(
|
||||
blockchain: Blockchain,
|
||||
derivationStyleProvider: DerivationStyleProvider,
|
||||
): String? {
|
||||
return blockchain.derivationPath(derivationStyleProvider.getDerivationStyle())?.rawPath
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtrasType {
|
||||
return when (this) {
|
||||
Blockchain.XRP -> Network.TransactionExtrasType.DESTINATION_TAG
|
||||
Blockchain.Binance,
|
||||
Blockchain.TON,
|
||||
Blockchain.Cosmos,
|
||||
Blockchain.TerraV1,
|
||||
Blockchain.TerraV2,
|
||||
Blockchain.Stellar,
|
||||
Blockchain.Hedera,
|
||||
Blockchain.Algorand,
|
||||
Blockchain.Sei,
|
||||
Blockchain.InternetComputer,
|
||||
Blockchain.Casper,
|
||||
-> Network.TransactionExtrasType.MEMO
|
||||
// region Other blockchains
|
||||
Blockchain.Unknown,
|
||||
Blockchain.Alephium,
|
||||
Blockchain.AlephiumTestnet,
|
||||
Blockchain.Arbitrum,
|
||||
Blockchain.ArbitrumTestnet,
|
||||
Blockchain.Avalanche,
|
||||
Blockchain.AvalancheTestnet,
|
||||
Blockchain.BinanceTestnet,
|
||||
Blockchain.BSC,
|
||||
Blockchain.BSCTestnet,
|
||||
Blockchain.Bitcoin,
|
||||
Blockchain.BitcoinTestnet,
|
||||
Blockchain.BitcoinCash,
|
||||
Blockchain.BitcoinCashTestnet,
|
||||
Blockchain.Cardano,
|
||||
Blockchain.CosmosTestnet,
|
||||
Blockchain.Dogecoin,
|
||||
Blockchain.Ducatus,
|
||||
Blockchain.Ethereum,
|
||||
Blockchain.EthereumTestnet,
|
||||
Blockchain.EthereumClassic,
|
||||
Blockchain.EthereumClassicTestnet,
|
||||
Blockchain.Fantom,
|
||||
Blockchain.FantomTestnet,
|
||||
Blockchain.Litecoin,
|
||||
Blockchain.Near,
|
||||
Blockchain.NearTestnet,
|
||||
Blockchain.Polkadot,
|
||||
Blockchain.PolkadotTestnet,
|
||||
Blockchain.Kava,
|
||||
Blockchain.KavaTestnet,
|
||||
Blockchain.Kusama,
|
||||
Blockchain.Polygon,
|
||||
Blockchain.PolygonTestnet,
|
||||
Blockchain.RSK,
|
||||
Blockchain.SeiTestnet,
|
||||
Blockchain.StellarTestnet,
|
||||
Blockchain.Solana,
|
||||
Blockchain.SolanaTestnet,
|
||||
Blockchain.Tezos,
|
||||
Blockchain.Tron,
|
||||
Blockchain.TronTestnet,
|
||||
Blockchain.Gnosis,
|
||||
Blockchain.Dash,
|
||||
Blockchain.Optimism,
|
||||
Blockchain.OptimismTestnet,
|
||||
Blockchain.Dischain,
|
||||
Blockchain.EthereumPow,
|
||||
Blockchain.EthereumPowTestnet,
|
||||
Blockchain.Kaspa,
|
||||
Blockchain.KaspaTestnet,
|
||||
Blockchain.Telos,
|
||||
Blockchain.TelosTestnet,
|
||||
Blockchain.TONTestnet,
|
||||
Blockchain.Ravencoin,
|
||||
Blockchain.Clore,
|
||||
Blockchain.RavencoinTestnet,
|
||||
Blockchain.Cronos,
|
||||
Blockchain.AlephZero,
|
||||
Blockchain.AlephZeroTestnet,
|
||||
Blockchain.OctaSpace,
|
||||
Blockchain.OctaSpaceTestnet,
|
||||
Blockchain.Chia,
|
||||
Blockchain.ChiaTestnet,
|
||||
Blockchain.Decimal,
|
||||
Blockchain.DecimalTestnet,
|
||||
Blockchain.XDC,
|
||||
Blockchain.XDCTestnet,
|
||||
Blockchain.VeChain,
|
||||
Blockchain.VeChainTestnet,
|
||||
Blockchain.Aptos,
|
||||
Blockchain.AptosTestnet,
|
||||
Blockchain.Playa3ull,
|
||||
Blockchain.Shibarium,
|
||||
Blockchain.ShibariumTestnet,
|
||||
Blockchain.AlgorandTestnet,
|
||||
Blockchain.HederaTestnet,
|
||||
Blockchain.Aurora,
|
||||
Blockchain.AuroraTestnet,
|
||||
Blockchain.Areon,
|
||||
Blockchain.AreonTestnet,
|
||||
Blockchain.PulseChain,
|
||||
Blockchain.PulseChainTestnet,
|
||||
Blockchain.ZkSyncEra,
|
||||
Blockchain.ZkSyncEraTestnet,
|
||||
Blockchain.Nexa,
|
||||
Blockchain.NexaTestnet,
|
||||
Blockchain.Moonbeam,
|
||||
Blockchain.MoonbeamTestnet,
|
||||
Blockchain.Manta,
|
||||
Blockchain.MantaTestnet,
|
||||
Blockchain.PolygonZkEVM,
|
||||
Blockchain.PolygonZkEVMTestnet,
|
||||
Blockchain.Radiant,
|
||||
Blockchain.Fact0rn,
|
||||
Blockchain.Base,
|
||||
Blockchain.BaseTestnet,
|
||||
Blockchain.Moonriver,
|
||||
Blockchain.MoonriverTestnet,
|
||||
Blockchain.Mantle,
|
||||
Blockchain.MantleTestnet,
|
||||
Blockchain.Flare,
|
||||
Blockchain.FlareTestnet,
|
||||
Blockchain.Taraxa,
|
||||
Blockchain.TaraxaTestnet,
|
||||
Blockchain.Koinos,
|
||||
Blockchain.KoinosTestnet,
|
||||
Blockchain.Joystream,
|
||||
Blockchain.Bittensor,
|
||||
Blockchain.Filecoin,
|
||||
Blockchain.Blast,
|
||||
Blockchain.BlastTestnet,
|
||||
Blockchain.Cyber,
|
||||
Blockchain.CyberTestnet,
|
||||
Blockchain.Sui,
|
||||
Blockchain.SuiTestnet,
|
||||
Blockchain.EnergyWebChain,
|
||||
Blockchain.EnergyWebChainTestnet,
|
||||
Blockchain.EnergyWebX,
|
||||
Blockchain.EnergyWebXTestnet,
|
||||
Blockchain.CasperTestnet,
|
||||
Blockchain.Core,
|
||||
Blockchain.CoreTestnet,
|
||||
Blockchain.Xodex,
|
||||
Blockchain.Canxium,
|
||||
Blockchain.Chiliz,
|
||||
Blockchain.ChilizTestnet,
|
||||
Blockchain.VanarChain,
|
||||
Blockchain.VanarChainTestnet,
|
||||
Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet,
|
||||
Blockchain.Bitrock, Blockchain.BitrockTestnet,
|
||||
Blockchain.Sonic, Blockchain.SonicTestnet,
|
||||
Blockchain.ApeChain, Blockchain.ApeChainTestnet,
|
||||
-> Network.TransactionExtrasType.NONE
|
||||
// endregion
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
package com.tangem.data.common.currency
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.blockchainsdk.utils.toCoinId
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.domain.common.util.cardTypesResolver
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import timber.log.Timber
|
||||
import com.tangem.blockchain.common.Token as SdkToken
|
||||
|
||||
class ResponseCryptoCurrenciesFactory(
|
||||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
) {
|
||||
|
||||
fun createCurrency(currencyId: String, response: UserTokensResponse, scanResponse: ScanResponse): CryptoCurrency {
|
||||
return response.tokens
|
||||
.asSequence()
|
||||
.mapNotNull { createCurrency(it, scanResponse) }
|
||||
.first { it.id.value == currencyId }
|
||||
}
|
||||
|
||||
fun createCurrencies(response: UserTokensResponse, scanResponse: ScanResponse): List<CryptoCurrency> {
|
||||
return response.tokens
|
||||
.asSequence()
|
||||
.mapNotNull { createCurrency(it, scanResponse) }
|
||||
.distinctBy { it.id }
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun createCurrency(responseToken: UserTokensResponse.Token, scanResponse: ScanResponse): CryptoCurrency? {
|
||||
var blockchain = Blockchain.fromNetworkId(responseToken.networkId)
|
||||
if (blockchain == null || blockchain == Blockchain.Unknown) {
|
||||
Timber.e("Unable to find a blockchain with the network ID: ${responseToken.networkId}")
|
||||
return null
|
||||
}
|
||||
|
||||
if (scanResponse.cardTypesResolver.isTestCard()) {
|
||||
blockchain = blockchain.getTestnetVersion() ?: blockchain
|
||||
}
|
||||
|
||||
val sdkToken = createSdkToken(responseToken)
|
||||
return if (sdkToken == null) {
|
||||
createCoin(blockchain, responseToken, scanResponse)
|
||||
} else {
|
||||
createToken(blockchain, sdkToken, responseToken.derivationPath, scanResponse)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSdkToken(token: UserTokensResponse.Token): SdkToken? {
|
||||
return token.contractAddress?.let { contractAddress ->
|
||||
SdkToken(
|
||||
name = token.name,
|
||||
symbol = token.symbol,
|
||||
contractAddress = contractAddress,
|
||||
decimals = token.decimals,
|
||||
id = token.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createCoin(
|
||||
blockchain: Blockchain,
|
||||
responseToken: UserTokensResponse.Token,
|
||||
scanResponse: ScanResponse,
|
||||
): CryptoCurrency.Coin? {
|
||||
val network = getNetwork(
|
||||
blockchain,
|
||||
responseToken.derivationPath,
|
||||
scanResponse,
|
||||
excludedBlockchains,
|
||||
) ?: return null
|
||||
|
||||
return CryptoCurrency.Coin(
|
||||
id = getCoinId(network, blockchain.toCoinId()),
|
||||
network = network,
|
||||
name = blockchain.getCoinName(),
|
||||
symbol = blockchain.getSymbolForCoin(responseToken),
|
||||
decimals = responseToken.decimals,
|
||||
iconUrl = getCoinIconUrl(blockchain),
|
||||
isCustom = isCustomCoin(network),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Blockchain.getSymbolForCoin(responseToken: UserTokensResponse.Token): String {
|
||||
return when (this) {
|
||||
// workaround: Dischain was renamed but backend still returns the old name,
|
||||
// get name and symbol from enum Blockchain until backend renamed
|
||||
// [REDACTED_JIRA]
|
||||
Blockchain.Dischain,
|
||||
Blockchain.Polygon,
|
||||
-> this.currency
|
||||
else -> responseToken.symbol
|
||||
}
|
||||
}
|
||||
|
||||
private fun createToken(
|
||||
blockchain: Blockchain,
|
||||
sdkToken: Token,
|
||||
responseDerivationPath: String?,
|
||||
scanResponse: ScanResponse,
|
||||
): CryptoCurrency.Token? {
|
||||
val network = getNetwork(
|
||||
blockchain,
|
||||
responseDerivationPath,
|
||||
scanResponse,
|
||||
excludedBlockchains,
|
||||
) ?: return null
|
||||
|
||||
val id = getTokenId(network, sdkToken)
|
||||
|
||||
return CryptoCurrency.Token(
|
||||
id = id,
|
||||
network = network,
|
||||
name = sdkToken.name,
|
||||
symbol = sdkToken.symbol,
|
||||
decimals = sdkToken.decimals,
|
||||
iconUrl = getTokenIconUrl(blockchain, sdkToken),
|
||||
contractAddress = sdkToken.contractAddress,
|
||||
isCustom = isCustomToken(id, network),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package com.tangem.data.common.currency
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.IconsUtil
|
||||
import com.tangem.blockchainsdk.utils.toCoinId
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency.ID
|
||||
import com.tangem.domain.tokens.model.Network
|
||||
import com.tangem.blockchain.common.Token as SdkToken
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency.ID.Body as CurrencyIdBody
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency.ID.Prefix.COIN_PREFIX as COIN_ID_PREFIX
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency.ID.Prefix.TOKEN_PREFIX as TOKEN_ID_PREFIX
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency.ID.Suffix.ContractAddress as CustomCurrencyIdSuffix
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency.ID.Suffix.RawID as CurrencyIdSuffix
|
||||
|
||||
private const val DEFAULT_TOKENS_ICONS_HOST = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins"
|
||||
private const val TOKEN_ICON_SIZE = "large"
|
||||
private const val TOKEN_ICON_EXT = "png"
|
||||
|
||||
fun isCustomToken(tokenId: ID, network: Network): Boolean {
|
||||
return network.derivationPath is Network.DerivationPath.Custom || tokenId.rawCurrencyId == null
|
||||
}
|
||||
|
||||
fun isCustomCoin(network: Network): Boolean {
|
||||
return network.derivationPath is Network.DerivationPath.Custom
|
||||
}
|
||||
|
||||
fun getCoinId(network: Network, coinId: String): ID {
|
||||
return ID(COIN_ID_PREFIX, getCurrencyIdBody(network), CurrencyIdSuffix(rawId = coinId))
|
||||
}
|
||||
|
||||
fun getTokenId(network: Network, sdkToken: SdkToken): ID {
|
||||
val sdkTokenId = sdkToken.id
|
||||
val tokenId = sdkTokenId?.let { CryptoCurrency.RawID(it) }
|
||||
|
||||
return getTokenId(network, tokenId, sdkToken.contractAddress)
|
||||
}
|
||||
|
||||
fun getTokenId(network: Network, rawTokenId: CryptoCurrency.RawID?, contractAddress: String): ID {
|
||||
val suffix = if (rawTokenId == null) {
|
||||
CustomCurrencyIdSuffix(contractAddress)
|
||||
} else {
|
||||
CurrencyIdSuffix(rawTokenId.value, contractAddress)
|
||||
}
|
||||
|
||||
return ID(TOKEN_ID_PREFIX, getCurrencyIdBody(network), suffix)
|
||||
}
|
||||
|
||||
fun getTokenIconUrl(blockchain: Blockchain, token: SdkToken): String? {
|
||||
val tokenId = token.id
|
||||
val rawID = tokenId?.let { CryptoCurrency.RawID(it) }
|
||||
|
||||
return if (rawID == null) {
|
||||
IconsUtil.getTokenIconUri(blockchain, token)?.toString()
|
||||
} else {
|
||||
getTokenIconUrlFromDefaultHost(rawID)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCoinIconUrl(blockchain: Blockchain): String? {
|
||||
val coinId = when (blockchain) {
|
||||
Blockchain.Unknown -> null
|
||||
else -> blockchain.toCoinId()
|
||||
}?.let { CryptoCurrency.RawID(it) }
|
||||
|
||||
return coinId?.let(::getTokenIconUrlFromDefaultHost)
|
||||
}
|
||||
|
||||
fun List<UserTokensResponse.Token>.hasCoinForToken(network: Network): Boolean {
|
||||
return any {
|
||||
val blockchain = getBlockchain(networkId = network.id)
|
||||
val tokenDerivation = network.derivationPath.value
|
||||
it.id == blockchain.toCoinId() && it.derivationPath == tokenDerivation
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrencyIdBody(network: Network): CurrencyIdBody {
|
||||
return when (val path = network.derivationPath) {
|
||||
is Network.DerivationPath.Custom -> CurrencyIdBody.NetworkIdWithDerivationPath(
|
||||
rawId = network.id.value,
|
||||
derivationPath = path.value,
|
||||
)
|
||||
is Network.DerivationPath.Card,
|
||||
is Network.DerivationPath.None,
|
||||
-> CurrencyIdBody.NetworkId(network.id.value)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTokenIconUrlFromDefaultHost(tokenId: CryptoCurrency.RawID): String {
|
||||
return buildString {
|
||||
append(DEFAULT_TOKENS_ICONS_HOST)
|
||||
append('/')
|
||||
append(TOKEN_ICON_SIZE)
|
||||
append('/')
|
||||
append(tokenId.value)
|
||||
append('.')
|
||||
append(TOKEN_ICON_EXT)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.tangem.data.common.currency
|
||||
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
|
||||
class UserTokensResponseFactory {
|
||||
|
||||
fun createUserTokensResponse(
|
||||
currencies: List<CryptoCurrency>,
|
||||
isGroupedByNetwork: Boolean,
|
||||
isSortedByBalance: Boolean,
|
||||
): UserTokensResponse {
|
||||
return UserTokensResponse(
|
||||
tokens = currencies.map(::createResponseToken),
|
||||
group = if (isGroupedByNetwork) {
|
||||
UserTokensResponse.GroupType.NETWORK
|
||||
} else {
|
||||
UserTokensResponse.GroupType.NONE
|
||||
},
|
||||
sort = if (isSortedByBalance) {
|
||||
UserTokensResponse.SortType.BALANCE
|
||||
} else {
|
||||
UserTokensResponse.SortType.MANUAL
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun createResponseToken(currency: CryptoCurrency): UserTokensResponse.Token {
|
||||
val blockchain = getBlockchain(currency.network.id)
|
||||
|
||||
return UserTokensResponse.Token(
|
||||
id = currency.id.rawCurrencyId?.value,
|
||||
networkId = blockchain.toNetworkId(),
|
||||
derivationPath = currency.network.derivationPath.value,
|
||||
name = currency.name,
|
||||
symbol = currency.symbol,
|
||||
decimals = currency.decimals,
|
||||
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.tangem.data.common.locale
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultLocaleProvider : LocaleProvider {
|
||||
|
||||
override fun getLocale(): Locale {
|
||||
return Locale.getDefault()
|
||||
}
|
||||
|
||||
override fun getWebUriLocaleLanguage(): String {
|
||||
val language = getLocale().language
|
||||
return if (LOCALE_LANG_RU.equals(language, true) || LOCALE_LANG_BY.equals(language, true)) {
|
||||
LOCALE_LANG_RU
|
||||
} else {
|
||||
LOCALE_LANG_EN
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LOCALE_LANG_RU = "ru"
|
||||
const val LOCALE_LANG_BY = "by"
|
||||
const val LOCALE_LANG_EN = "en"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.data.common.locale
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
interface LocaleProvider {
|
||||
|
||||
fun getLocale(): Locale
|
||||
|
||||
fun getWebUriLocaleLanguage(): String
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.data.common.locale.di
|
||||
|
||||
import com.tangem.data.common.locale.DefaultLocaleProvider
|
||||
import com.tangem.data.common.locale.LocaleProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object LocaleProviderModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCacheRegistry(): LocaleProvider {
|
||||
return DefaultLocaleProvider()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.tangem.data.common.utils
|
||||
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.yield
|
||||
import timber.log.Timber
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Suppress("UnconditionalJumpStatementInLoop", "MagicNumber")
|
||||
suspend fun <T> retryOnError(priority: Boolean = false, startRetryDelay: Int = 500, call: suspend () -> T): T {
|
||||
var currentDelay = startRetryDelay
|
||||
var priorityCounter = 5
|
||||
while (true) {
|
||||
return try {
|
||||
call()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
}
|
||||
|
||||
Timber.e(e, "Error occurred during retryOnError block")
|
||||
|
||||
if (priority && priorityCounter > 0) {
|
||||
--priorityCounter
|
||||
} else {
|
||||
yield()
|
||||
delay(timeMillis = currentDelay.toLong())
|
||||
currentDelay *= 2
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue