Updated on 2026-08-14

This commit is contained in:
Tangem 2024-01-10 15:56:05 +03:00
commit 8bba8e1afa
825 changed files with 9837 additions and 22210 deletions

View file

@ -53,6 +53,7 @@ internal class DefaultCardSdkConfigRepository(
ProductType.Wallet2,
ProductType.Start2Coin,
ProductType.Ring,
ProductType.Visa,
-> CardIdDisplayFormat.Full
}
}

View file

@ -31,12 +31,16 @@ value class ApiResponseRaise(
/**
* 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.
*/
inline fun <T> safeApiCall(call: ApiResponseRaise.() -> T, onError: (ApiResponseError) -> T): T {
suspend inline fun <T> safeApiCall(
crossinline call: suspend ApiResponseRaise.() -> T,
crossinline onError: suspend (ApiResponseError) -> T,
): T {
return recover(
block = { call(ApiResponseRaise(raise = this)) },
recover = {

View file

@ -1,73 +0,0 @@
package com.tangem.data.source.preferences
import android.content.SharedPreferences
import androidx.core.content.edit
import java.util.*
@Deprecated("Create repository instead")
class AppRatingLaunchObserver internal constructor(
private val preferences: SharedPreferences,
private val launchCounts: Int,
) {
private val deferShowing = 20
private val firstShowing = 3
private var fundsFoundDate: Calendar? = null
init {
val msWhenFundsWasFound = preferences.getLong(K_FUNDS_FOUND_DATE, FUNDS_FOUND_DATE_UNDEFINED)
if (msWhenFundsWasFound != FUNDS_FOUND_DATE_UNDEFINED) {
fundsFoundDate = Calendar.getInstance().apply { timeInMillis = msWhenFundsWasFound }
}
}
fun foundWalletWithFunds() {
if (fundsFoundDate != null) return
fundsFoundDate = Calendar.getInstance()
preferences.edit(true) {
putLong(K_FUNDS_FOUND_DATE, fundsFoundDate!!.timeInMillis).apply()
putInt(K_SHOW_RATING_AT_LAUNCH_COUNT, launchCounts + firstShowing)
}
}
fun isReadyToShow(): Boolean {
val fundsDate = fundsFoundDate ?: return false
if (!userWasInteractWithRating()) {
val diff = Calendar.getInstance().timeInMillis - fundsDate.timeInMillis
val diffInDays = diff / DAY_MILLIS
return launchCounts >= getCounterOfNextShowing() && diffInDays >= firstShowing
}
val nextShowing = getCounterOfNextShowing()
return launchCounts >= nextShowing
}
fun applyDelayedShowing() {
updateNextShowing(launchCounts + deferShowing)
}
fun setNeverToShow() {
updateNextShowing(Int.MAX_VALUE)
}
private fun updateNextShowing(at: Int) {
val editor = preferences.edit()
editor.putInt(K_SHOW_RATING_AT_LAUNCH_COUNT, at)
editor.putBoolean(K_USER_WAS_INTERACT_WITH_RATING, true)
editor.apply()
}
private fun userWasInteractWithRating(): Boolean = preferences.getBoolean(K_USER_WAS_INTERACT_WITH_RATING, false)
private fun getCounterOfNextShowing(): Int = preferences.getInt(K_SHOW_RATING_AT_LAUNCH_COUNT, firstShowing)
companion object {
private const val K_SHOW_RATING_AT_LAUNCH_COUNT = "showRatingDialogAtLaunchCount"
private const val K_FUNDS_FOUND_DATE = "fundsFoundDate"
private const val K_USER_WAS_INTERACT_WITH_RATING = "userWasInteractWithRating"
private const val FUNDS_FOUND_DATE_UNDEFINED = -1L
private const val DAY_MILLIS = 1000 * 60 * 60 * 24
}
}

View file

@ -5,10 +5,7 @@ import android.content.SharedPreferences
import androidx.core.content.edit
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.data.source.preferences.adapters.BigDecimalAdapter
import com.tangem.data.source.preferences.adapters.CardBalanceStateAdapter
import com.tangem.data.source.preferences.storage.DisclaimerPrefStorage
import com.tangem.data.source.preferences.storage.FiatCurrenciesPrefStorage
import com.tangem.data.source.preferences.storage.ToppedUpWalletStorage
import com.tangem.data.source.preferences.storage.UsedCardsPrefStorage
import javax.inject.Inject
@ -17,35 +14,24 @@ import javax.inject.Inject
@Deprecated("Create repository instead")
class PreferencesDataSource @Inject internal constructor(applicationContext: Context) {
val appRatingLaunchObserver: AppRatingLaunchObserver
val usedCardsPrefStorage: UsedCardsPrefStorage
val fiatCurrenciesPrefStorage: FiatCurrenciesPrefStorage
val disclaimerPrefStorage: DisclaimerPrefStorage
val toppedUpWalletStorage: ToppedUpWalletStorage
private val preferences: SharedPreferences =
applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
private val moshiConverter = MoshiJsonConverter(
adapters = listOf(BigDecimalAdapter(), CardBalanceStateAdapter()) + MoshiJsonConverter.getTangemSdkAdapters(),
adapters = listOf(BigDecimalAdapter()) + MoshiJsonConverter.getTangemSdkAdapters(),
typedAdapters = MoshiJsonConverter.getTangemSdkTypedAdapters(),
)
init {
incrementLaunchCounter()
appRatingLaunchObserver = AppRatingLaunchObserver(preferences, getCountOfLaunches())
usedCardsPrefStorage = UsedCardsPrefStorage(preferences, moshiConverter)
usedCardsPrefStorage.migrate()
fiatCurrenciesPrefStorage = FiatCurrenciesPrefStorage(preferences, moshiConverter)
fiatCurrenciesPrefStorage.migrate()
disclaimerPrefStorage = DisclaimerPrefStorage(preferences)
toppedUpWalletStorage = ToppedUpWalletStorage(preferences, moshiConverter)
}
var sprinklrFirstLaunchTime: Long?
get() = preferences.getLong(SPRINKLR_FIRST_LAUNCH_KEY, 0).takeIf { it != 0L }
set(value) = preferences.edit { putLong(SPRINKLR_FIRST_LAUNCH_KEY, value ?: 0) }
var shouldShowSaveUserWalletScreen: Boolean
get() = preferences.getBoolean(SAVE_WALLET_DIALOG_SHOWN_KEY, true)
set(value) = preferences.edit {
@ -78,8 +64,6 @@ class PreferencesDataSource @Inject internal constructor(applicationContext: Con
return preferences.getBoolean(TWINS_ONBOARDING_SHOWN_KEY, false)
}
private fun getCountOfLaunches(): Int = preferences.getInt(APP_LAUNCH_COUNT_KEY, 1)
private fun incrementLaunchCounter() {
var count = preferences.getInt(APP_LAUNCH_COUNT_KEY, 0)
preferences.edit { putInt(APP_LAUNCH_COUNT_KEY, ++count) }
@ -89,7 +73,6 @@ class PreferencesDataSource @Inject internal constructor(applicationContext: Con
private const val PREFERENCES_NAME = "tapPrefs"
private const val TWINS_ONBOARDING_SHOWN_KEY = "twinsOnboardingShown"
private const val APP_LAUNCH_COUNT_KEY = "launchCount"
private const val SPRINKLR_FIRST_LAUNCH_KEY = "sprinklrFirstLaunch"
private const val SAVE_WALLET_DIALOG_SHOWN_KEY = "saveUserWalletShown"
private const val SAVE_ACCESS_CODES_KEY = "saveAccessCodes"
private const val APPLICATION_STOPPED_KEY = "applicationStopped"

View file

@ -1,20 +0,0 @@
package com.tangem.data.source.preferences.adapters
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import com.tangem.data.source.preferences.model.DataSourceTopupInfo
class CardBalanceStateAdapter {
@ToJson
fun toJson(src: DataSourceTopupInfo.CardBalanceState): String = src.serializedName
@FromJson
fun fromJson(json: String): DataSourceTopupInfo.CardBalanceState {
return when (json) {
DataSourceTopupInfo.CardBalanceState.Empty.serializedName -> DataSourceTopupInfo.CardBalanceState.Empty
DataSourceTopupInfo.CardBalanceState.Full.serializedName -> DataSourceTopupInfo.CardBalanceState.Full
else -> error("CardBalanceState not found")
}
}
}

View file

@ -1,10 +0,0 @@
package com.tangem.data.source.preferences.model
data class DataSourceCurrency(
val id: String,
val code: String,
val name: String,
val rateBTC: String,
val unit: String,
val type: String,
)

View file

@ -1,7 +0,0 @@
package com.tangem.data.source.preferences.model
data class DataSourceFiatCurrency(
val code: String,
val name: String,
val symbol: String,
)

View file

@ -1,14 +0,0 @@
package com.tangem.data.source.preferences.model
data class DataSourceTopupInfo(
val walletId: String,
val cardBalanceState: CardBalanceState,
) {
enum class CardBalanceState(val serializedName: String) {
Empty(serializedName = "Empty"),
Full(serializedName = "Full"),
CustomToken(serializedName = "Custom token"),
BlockchainError(serializedName = "Blockchain error"),
NoRate(serializedName = "No rate"),
}
}

View file

@ -1,56 +0,0 @@
package com.tangem.data.source.preferences.storage
import android.content.SharedPreferences
import androidx.core.content.edit
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.data.source.preferences.model.DataSourceCurrency
import com.tangem.data.source.preferences.model.DataSourceFiatCurrency
/**
[REDACTED_AUTHOR]
*/
@Deprecated("Create repository instead")
class FiatCurrenciesPrefStorage internal constructor(
private val preferences: SharedPreferences,
private val converter: MoshiJsonConverter,
) {
fun migrate() {
preferences.edit(true) {
remove(FIAT_CURRENCIES_KEY_OLD)
remove(APP_CURRENCY_KEY_OLD)
}
}
fun getAppCurrency(): DataSourceFiatCurrency? {
val json = preferences.getString(APP_CURRENCY_KEY, "")
if (json.isNullOrBlank()) return null
return converter.fromJson(json)
}
fun saveAppCurrency(fiatCurrency: DataSourceFiatCurrency) {
val json = converter.toJson(fiatCurrency)
preferences.edit { putString(APP_CURRENCY_KEY, json) }
}
fun save(currencies: List<DataSourceCurrency>) {
val json: String = converter.toJson(currencies)
return preferences.edit().putString(FIAT_CURRENCIES_KEY, json).apply()
}
fun restore(): List<DataSourceCurrency> {
val json = preferences.getString(FIAT_CURRENCIES_KEY, "")
val type = converter.typedList(DataSourceCurrency::class.java)
if (json.isNullOrBlank()) return emptyList()
return converter.fromJson(json, type) ?: emptyList()
}
companion object {
private const val FIAT_CURRENCIES_KEY_OLD = "fiatCurrencies"
private const val APP_CURRENCY_KEY_OLD = "appCurrency"
private const val FIAT_CURRENCIES_KEY = "fiatCurrencies_v2"
private const val APP_CURRENCY_KEY = "appCurrency_v2"
}
}

View file

@ -1,58 +0,0 @@
package com.tangem.data.source.preferences.storage
import android.content.SharedPreferences
import androidx.core.content.edit
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.data.source.preferences.model.DataSourceTopupInfo
/**
[REDACTED_AUTHOR]
*/
@Deprecated("Create repository instead")
class ToppedUpWalletStorage internal constructor(
private val preferences: SharedPreferences,
private val jsonConverter: MoshiJsonConverter,
) {
private val walletList: MutableSet<DataSourceTopupInfo> = mutableSetOf()
init {
walletList.addAll(restore())
}
fun save(userWalletInfo: DataSourceTopupInfo): Boolean {
walletList.removeAll { it.walletId == userWalletInfo.walletId }
walletList.add(userWalletInfo)
return save(walletList)
}
fun restore(walletId: String): DataSourceTopupInfo? {
return walletList.firstOrNull { it.walletId == walletId }
}
private fun save(userWallets: MutableSet<DataSourceTopupInfo>): Boolean {
return try {
val json = jsonConverter.toJson(userWallets)
preferences.edit(true) { putString(KEY, json) }
true
} catch (ex: Exception) {
false
}
}
private fun restore(): MutableSet<DataSourceTopupInfo> {
val json = preferences.getString(KEY, null) ?: return mutableSetOf()
return try {
val typedList = jsonConverter.typedList(DataSourceTopupInfo::class.java)
val listData = jsonConverter.fromJson<List<DataSourceTopupInfo>>(json, typedList)!!
listData.toMutableSet()
} catch (ex: Exception) {
preferences.edit(true) { remove(KEY) }
mutableSetOf()
}
}
companion object {
private const val KEY = "userWalletsInfo"
}
}

View file

@ -102,4 +102,13 @@ internal object TokensDataModule {
quotesRepository = quotesRepository,
)
}
@Provides
@Singleton
fun provideCardNetworksRepository(
userWalletsStore: UserWalletsStore,
dispatchers: CoroutineDispatcherProvider,
): NetworksCompatibilityRepository {
return DefaultNetworksCompatibilityRepository(userWalletsStore = userWalletsStore, dispatchers = dispatchers)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.tokens.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.tokens.model.CryptoCurrency
@ -41,7 +42,7 @@ internal class CoinsPagingSource(
searchText = searchText,
offset = page * params.loadSize,
limit = params.loadSize,
)
).getOrThrow()
}.fold(
onSuccess = { response ->
val coinsIds = response.coins.map { coin ->

View file

@ -32,6 +32,7 @@ internal object CoinsResponseConverter : Converter<CoinsData, List<Token>> {
Token.Network(
networkId = network.networkId,
standardType = getNetworkStandardType(blockchain).name,
name = blockchain.fullName,
address = network.contractAddress,
iconUrl = getIconUrl(network.networkId, value.imageHost),
decimalCount = network.decimalCount?.toInt(),

View file

@ -49,6 +49,7 @@ internal class DefaultCurrenciesRepository(
private val cardCurrenciesFactory = CardCryptoCurrenciesFactory(demoConfig)
private val userTokensResponseFactory = UserTokensResponseFactory()
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
private val customTokensMerger = CustomTokensMerger(tangemTechApi, dispatchers)
override suspend fun saveTokens(
userWalletId: UserWalletId,
@ -313,6 +314,7 @@ internal class DefaultCurrenciesRepository(
): Boolean {
val blockchain = Blockchain.fromId(cryptoCurrencyStatus.currency.network.id.value)
val isBitcoinBlockchain = blockchain == Blockchain.Bitcoin || blockchain == Blockchain.BitcoinTestnet
return if (cryptoCurrencyStatus.currency is CryptoCurrency.Coin && isBitcoinBlockchain) {
val outgoingTransactions = cryptoCurrencyStatus.value.pendingTransactions.filter { it.isOutgoing }
outgoingTransactions.isNotEmpty()
@ -341,40 +343,32 @@ internal class DefaultCurrenciesRepository(
private suspend fun fetchTokens(userWallet: UserWallet) {
val userWalletId = userWallet.walletId
if (demoConfig.isDemoCardId(userWallet.cardId) && userTokensStore.getSyncOrNull(key = userWalletId) == null) {
userTokensStore.store(
key = userWalletId,
value = userTokensResponseFactory.createUserTokensResponse(
currencies = cardCurrenciesFactory.createDefaultCoinsForMultiCurrencyCard(userWallet.scanResponse),
isGroupedByNetwork = false,
isSortedByBalance = false,
),
)
return
val response = if (checkIsEmptyDemoWallet(userWallet)) {
createDefaultUserTokensResponse(userWallet)
} else {
safeApiCall({ tangemTechApi.getUserTokens(userWalletId.stringValue).bind() }) {
handleFetchTokensError(userWallet, it)
}
}
val response = safeApiCall(
call = {
tangemTechApi.getUserTokens(userWalletId.stringValue).bind().let {
it.copy(tokens = it.tokens.distinct())
}
},
onError = { handleFetchTokensError(userWallet, it) },
)
val compatibleUserTokensResponse = response
.let { it.copy(tokens = it.tokens.distinct()) }
.let { customTokensMerger.mergeIfPresented(userWalletId, response) }
.let(userTokensBackwardCompatibility::applyCompatibilityAndGetUpdated)
val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response)
userTokensStore.store(userWallet.walletId, compatibleUserTokensResponse)
fetchExchangeableUserMarketCoinsByIds(userWalletId, compatibleUserTokensResponse)
}
private suspend fun checkIsEmptyDemoWallet(userWallet: UserWallet): Boolean {
return demoConfig.isDemoCardId(userWallet.cardId) && userTokensStore.getSyncOrNull(userWallet.walletId) == null
}
private suspend fun storeAndPushTokens(userWalletId: UserWalletId, response: UserTokensResponse) {
val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response)
userTokensStore.store(userWalletId, compatibleUserTokensResponse)
try {
tangemTechApi.saveUserTokens(userWalletId.stringValue, response)
} catch (e: Throwable) {
Timber.e("Unable to save user tokens for: ${userWalletId.stringValue}")
}
pushTokens(userWalletId, response)
}
private suspend fun fetchExchangeableUserMarketCoinsByIds(
@ -407,16 +401,12 @@ internal class DefaultCurrenciesRepository(
private suspend fun handleFetchTokensError(userWallet: UserWallet, e: ApiResponseError): UserTokensResponse {
val userWalletId = userWallet.walletId
val response = userTokensStore.getSyncOrNull(userWalletId)
?: userTokensResponseFactory.createUserTokensResponse(
currencies = cardCurrenciesFactory.createDefaultCoinsForMultiCurrencyCard(userWallet.scanResponse),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
?: createDefaultUserTokensResponse(userWallet)
if (e is ApiResponseError.HttpException && e.code == ApiResponseError.HttpException.Code.NOT_FOUND) {
Timber.w(e, "Requested currencies could not be found in the remote store for: $userWalletId")
tangemTechApi.saveUserTokens(userWalletId.stringValue, response)
pushTokens(userWalletId, response)
} else {
cacheRegistry.invalidate(getTokensCacheKey(userWalletId))
}
@ -424,6 +414,19 @@ internal class DefaultCurrenciesRepository(
return response
}
private suspend fun pushTokens(userWalletId: UserWalletId, response: UserTokensResponse) {
safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, response).bind() }) {
Timber.e(it, "Unable to save user tokens for: ${userWalletId.stringValue}")
}
}
private fun createDefaultUserTokensResponse(userWallet: UserWallet) =
userTokensResponseFactory.createUserTokensResponse(
currencies = cardCurrenciesFactory.createDefaultCoinsForMultiCurrencyCard(userWallet.scanResponse),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
private suspend fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"Unable to find a user wallet with provided ID: $userWalletId"

View file

@ -0,0 +1,89 @@
package com.tangem.data.tokens.repository
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.tokens.utils.getNetwork
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.extensions.*
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.NetworksCompatibilityRepository
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
internal class DefaultNetworksCompatibilityRepository(
private val userWalletsStore: UserWalletsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : NetworksCompatibilityRepository {
/**
* @return returns true if either the network is not Solana (check is not relevant) or if it is Solana and
* UserWallet supports tokens on Solana Network
*/
@Throws(IllegalArgumentException::class)
override suspend fun areSolanaTokensSupportedIfRelevant(networkId: String, userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.io) {
val scanResponse = getWalletOrThrow(userWalletId).scanResponse
val blockchain = getBlockchainOrThrow(networkId)
val blockchainsSupportingTokens = scanResponse.card.supportedTokens(scanResponse.cardTypesResolver)
blockchain != Blockchain.Solana || blockchainsSupportingTokens.contains(Blockchain.Solana)
}
}
@Throws(IllegalArgumentException::class)
override suspend fun areTokensSupportedByNetwork(networkId: String, userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.io) {
val scanResponse = getWalletOrThrow(userWalletId).scanResponse
val blockchain = getBlockchainOrThrow(networkId)
val blockchainsSupportingTokens = scanResponse.card.supportedTokens(scanResponse.cardTypesResolver)
scanResponse.card.canHandleToken(
supportedTokens = blockchainsSupportingTokens,
blockchain = blockchain,
cardTypesResolver = scanResponse.cardTypesResolver,
)
}
}
@Throws(IllegalArgumentException::class)
override suspend fun isNetworkSupported(networkId: String, userWalletId: UserWalletId): Boolean {
return withContext(dispatchers.io) {
val scanResponse = getWalletOrThrow(userWalletId).scanResponse
val blockchain = getBlockchainOrThrow(networkId)
scanResponse.card.canHandleBlockchain(
blockchain = blockchain,
cardTypesResolver = scanResponse.cardTypesResolver,
)
}
}
@Throws(IllegalArgumentException::class)
override suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> {
val scanResponse = getWalletOrThrow(userWalletId).scanResponse
return Blockchain.values()
.filter { blockchain ->
scanResponse.card.supportedBlockchains(scanResponse.cardTypesResolver).contains(blockchain)
}
.sortedBy(Blockchain::fullName)
.mapNotNull { blockchain ->
getNetwork(blockchain, null, scanResponse.derivationStyleProvider)
}
}
override fun areTokensSupportedByNetwork(networkId: String): Boolean {
return Blockchain.fromNetworkId(networkId)?.canHandleTokens() ?: false
}
private suspend fun getWalletOrThrow(userWalletId: UserWalletId): UserWallet {
return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"Requested UserWallet not found"
}
}
private fun getBlockchainOrThrow(networkId: String): Blockchain {
return requireNotNull(Blockchain.fromNetworkId(networkId)) {
"Requested network not found"
}
}
}

View file

@ -186,6 +186,7 @@ internal class DefaultNetworksRepository(
is UpdateWalletManagerResult.NoAccount,
-> Unit
is UpdateWalletManagerResult.Unreachable,
is UpdateWalletManagerResult.UnreachableWithoutAddresses,
is UpdateWalletManagerResult.MissedDerivation,
-> {
Timber.w(

View file

@ -6,7 +6,6 @@ import com.tangem.data.tokens.utils.QuotesConverter
import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.local.*
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject

View file

@ -3,13 +3,19 @@ package com.tangem.data.tokens.repository
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.tokens.paging.CoinsPagingSource
import com.tangem.data.tokens.utils.FoundTokenConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.model.FoundToken
import com.tangem.domain.tokens.model.Token
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.tokens.repository.TokensListRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
/**
* Default repository implementation for managing operations related to a complete set of tokens
@ -42,4 +48,32 @@ internal class DefaultTokensListRepository(
},
).flow
}
override suspend fun findToken(contractAddress: String, networkId: String): FoundToken? {
return withContext(dispatchers.io) {
val foundCoin = tangemTechApi.getCoins(
contractAddress = contractAddress,
networkIds = networkId,
).getOrThrow().coins.firstNotNullOfOrNull { coin ->
val tokenNetwork = coin.networks.filter { network ->
network.contractAddress != null && network.decimalCount != null &&
network.contractAddress?.equals(contractAddress, ignoreCase = true) == true &&
networkId == network.networkId
}
if (tokenNetwork.isNotEmpty()) {
coin.copy(networks = tokenNetwork)
} else {
null
}
}
foundCoin?.let { FoundTokenConverter.convert(foundCoin) }
}
}
override fun validateAddress(contractAddress: String, networkId: String): Boolean {
return when (val blockchain = Blockchain.fromNetworkId(networkId) ?: Blockchain.Unknown) {
Blockchain.Unknown, Blockchain.Binance, Blockchain.BinanceTestnet -> true
else -> blockchain.validateAddress(contractAddress)
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.tokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.DerivationStyleProvider
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.extensions.toCoinId
import com.tangem.domain.tokens.model.CryptoCurrency
import timber.log.Timber
@ -57,4 +58,43 @@ class CryptoCurrencyFactory {
isCustom = isCustomCoin(network),
)
}
fun createCoin(
networkId: String,
extraDerivationPath: String?,
derivationStyleProvider: DerivationStyleProvider,
): CryptoCurrency.Coin? {
val blockchain = Blockchain.fromNetworkId(networkId) ?: Blockchain.Unknown
return createCoin(blockchain, extraDerivationPath, derivationStyleProvider)
}
fun createToken(
token: Token,
networkId: String,
extraDerivationPath: String?,
derivationStyleProvider: DerivationStyleProvider,
): 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,
derivationStyleProvider = derivationStyleProvider,
)
}
data class Token(
val name: String,
val symbol: String,
val contractAddress: String,
val decimals: Int,
val id: String? = null,
)
}

View file

@ -0,0 +1,95 @@
package com.tangem.data.tokens.utils
import com.tangem.data.common.api.safeApiCall
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
* Responsible for merging custom tokens into a user's token response.
* It handles the logic to update tokens with additional details if necessary.
*/
internal class CustomTokensMerger(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
) {
/**
* Merges custom tokens into the user's token response if needed.
*
* This function processes each token in the response asynchronously, checking if an update
* is needed, and if so, updating the token from [TangemTechApi.getCoins] response. It then pushes to the backend
* and returns an updated UserTokensResponse.
*
* @param userWalletId The identifier for the user's wallet, used when pushing updates.
* @param response The original user tokens response that may need to be updated.
* @return A potentially updated UserTokensResponse, with custom tokens merged if necessary.
*/
suspend fun mergeIfPresented(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse {
val mergedTokens = withContext(dispatchers.default) {
response.tokens
.map { token ->
async { mergeIfPresented(token) }
}
.awaitAll()
}
val updatedResponse = response.copy(tokens = mergedTokens)
if (response.tokens != updatedResponse.tokens) {
pushTokens(userWalletId, updatedResponse)
}
return updatedResponse
}
private suspend fun mergeIfPresented(token: UserTokensResponse.Token): UserTokensResponse.Token {
if (isCoinOrNonCustomToken(token)) return token
return merge(token)
}
private suspend fun merge(customToken: UserTokensResponse.Token): UserTokensResponse.Token {
val foundToken = fetchToken(customToken)
return foundToken ?: customToken
}
private fun isCoinOrNonCustomToken(token: UserTokensResponse.Token): Boolean {
return token.contractAddress.isNullOrEmpty() || token.id != null
}
private suspend fun fetchToken(token: UserTokensResponse.Token): UserTokensResponse.Token? {
val response = withContext(dispatchers.io) {
safeApiCall(
call = {
tangemTechApi.getCoins(
contractAddress = token.contractAddress,
networkIds = token.networkId,
).bind()
},
onError = {
Timber.w(it, "Unable to fetch token")
null
},
)
}
val foundToken = response?.coins?.firstOrNull() ?: return null
return token.copy(
id = foundToken.id,
name = foundToken.name,
symbol = foundToken.symbol,
)
}
private suspend fun pushTokens(userWalletId: UserWalletId, response: UserTokensResponse) {
safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, response).bind() }) {
Timber.e(it, "Unable to save user tokens for: $userWalletId")
}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.tokens.utils
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.tokens.model.FoundToken
import com.tangem.utils.converter.Converter
internal object FoundTokenConverter : Converter<CoinsResponse.Coin, FoundToken> {
override fun convert(value: CoinsResponse.Coin): FoundToken {
return FoundToken(
id = value.id,
name = value.name,
symbol = value.symbol,
contractAddress = requireNotNull(value.networks.first().contractAddress),
decimals = requireNotNull(value.networks.first().decimalCount).intValueExact(),
)
}
}

View file

@ -19,7 +19,10 @@ internal class NetworkStatusFactory {
network = network,
value = when (result) {
is UpdateWalletManagerResult.MissedDerivation -> NetworkStatus.MissedDerivation
is UpdateWalletManagerResult.Unreachable -> NetworkStatus.Unreachable
is UpdateWalletManagerResult.UnreachableWithoutAddresses -> NetworkStatus.UnreachableWithoutAddresses
is UpdateWalletManagerResult.Unreachable -> NetworkStatus.Unreachable(
address = getNetworkAddress(result.selectedAddress, result.addresses),
)
is UpdateWalletManagerResult.NoAccount -> NetworkStatus.NoAccount(
address = getNetworkAddress(result.selectedAddress, result.addresses),
amountToCreateAccount = result.amountToCreateAccount,

1
data/transaction/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,30 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.data.transaction"
}
dependencies {
/** Tangem SDKs */
implementation(deps.tangem.blockchain)
/** Core */
implementation(projects.core.utils)
/** Domain */
implementation(projects.domain.transaction)
implementation(projects.domain.legacy)
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens.models)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,65 @@
package com.tangem.data.transaction
import androidx.core.text.isDigitsOnly
import com.tangem.blockchain.blockchains.binance.BinanceTransactionExtras
import com.tangem.blockchain.blockchains.cosmos.CosmosTransactionExtras
import com.tangem.blockchain.blockchains.stellar.StellarMemo
import com.tangem.blockchain.blockchains.stellar.StellarTransactionExtras
import com.tangem.blockchain.blockchains.ton.TonTransactionExtras
import com.tangem.blockchain.blockchains.xrp.XrpTransactionBuilder
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionExtras
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
internal class DefaultTransactionRepository(
private val walletManagersFacade: WalletManagersFacade,
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
) : TransactionRepository {
override suspend fun createTransaction(
amount: Amount,
fee: Fee,
memo: String?,
destination: String,
userWalletId: UserWalletId,
network: Network,
): TransactionData? = withContext(coroutineDispatcherProvider.io) {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
return@withContext walletManager?.createTransaction(amount, fee, destination)?.copy(
extras = getMemoExtras(network.id.value, memo),
)
}
private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? {
val blockchain = Blockchain.fromId(networkId)
if (memo == null) return null
return when (blockchain) {
Blockchain.Stellar -> {
val xmlMemo = when {
memo.isNotEmpty() && memo.isDigitsOnly() -> StellarMemo.Id(memo.toBigInteger())
else -> StellarMemo.Text(memo)
}
StellarTransactionExtras(xmlMemo)
}
Blockchain.Binance -> BinanceTransactionExtras(memo)
Blockchain.XRP -> memo.toLongOrNull()?.let { XrpTransactionBuilder.XrpTransactionExtras(it) }
Blockchain.Cosmos -> CosmosTransactionExtras(memo)
Blockchain.TON -> TonTransactionExtras(memo)
else -> null
}
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.data.transaction.di
import com.tangem.data.transaction.DefaultTransactionRepository
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
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 TransactionDataModule {
@Provides
@Singleton
fun providesTransactionRepository(
walletManagersFacade: WalletManagersFacade,
coroutineDispatcherProvider: CoroutineDispatcherProvider,
): TransactionRepository {
return DefaultTransactionRepository(
walletManagersFacade = walletManagersFacade,
coroutineDispatcherProvider = coroutineDispatcherProvider,
)
}
}

View file

@ -66,7 +66,12 @@ class DefaultTxHistoryRepository(
override fun getTxExploreUrl(txHash: String, networkId: Network.ID): String {
val blockchain = Blockchain.fromId(networkId.value)
return blockchain.getExploreTxUrl(txHash)
// TODO: Fix ton tx urls [REDACTED_TASK_KEY]
return if (blockchain == Blockchain.TON || blockchain == Blockchain.TONTestnet) {
""
} else {
blockchain.getExploreTxUrl(txHash)
}
}
private suspend fun getUserWallet(userWalletId: UserWalletId): UserWallet {

View file

@ -2,8 +2,10 @@ package com.tangem.data.txhistory.repository.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.txhistory.models.PaginationWrapper
import com.tangem.domain.txhistory.models.TxHistoryItem
@ -20,6 +22,8 @@ internal class TxHistoryPagingSource(
private val storeKey = TxHistoryItemsStore.Key(sourceParams.userWalletId, sourceParams.currency)
override val keyReuseSupported: Boolean get() = true
override fun getRefreshKey(state: PagingState<Int, TxHistoryItem>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
@ -70,14 +74,54 @@ internal class TxHistoryPagingSource(
}
private suspend fun fetch(pageToLoad: Int, pageSize: Int) {
val wrappedItems = walletManagersFacade.getTxHistoryItems(
var wrappedItems = walletManagersFacade.getTxHistoryItems(
userWalletId = sourceParams.userWalletId,
currency = sourceParams.currency,
page = pageToLoad,
pageSize = pageSize,
)
txHistoryItemsStore.store(storeKey, wrappedItems)
if (pageToLoad == 1) {
wrappedItems = wrappedItems.addRecentTransactions()
}
txHistoryItemsStore.store(key = storeKey, value = wrappedItems)
}
private suspend fun PaginationWrapper<TxHistoryItem>.addRecentTransactions(): PaginationWrapper<TxHistoryItem> {
val recentTxHistoryItems = Blockchain.fromNetworkId(sourceParams.currency.network.backendId)?.let {
walletManagersFacade.getRecentTransactions(
userWalletId = sourceParams.userWalletId,
blockchain = it,
derivationPath = sourceParams.currency.network.derivationPath.value,
)
.filterUnconfirmedTransaction()
.filterIfApiKnowsAboutTx(apiItems = items)
} ?: emptyList()
return if (recentTxHistoryItems.isEmpty()) {
Timber.d("Nothing to add to TxHistory")
this
} else {
Timber.d(
"Recent transactions were added to TxHistory: %s",
recentTxHistoryItems.joinToString(
prefix = "[",
postfix = "]",
transform = TxHistoryItem::txHash,
),
)
return copy(items = recentTxHistoryItems + items)
}
}
private fun List<TxHistoryItem>.filterUnconfirmedTransaction(): List<TxHistoryItem> {
return filter { it.status == TxHistoryItem.TransactionStatus.Unconfirmed }
}
private fun List<TxHistoryItem>.filterIfApiKnowsAboutTx(apiItems: List<TxHistoryItem>): List<TxHistoryItem> {
return filter { item -> apiItems.none { it.txHash == item.txHash } }
}
private fun getTxHistoryPageKey(page: Int): String {

View file

@ -11,14 +11,27 @@ android {
}
dependencies {
/** Tangem libraries */
implementation(deps.tangem.blockchain) // android-library
/** Core */
implementation(projects.core.datasource)
implementation(projects.core.utils)
/** Domain */
implementation(projects.domain.wallets)
/** Domain models */
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens.models)
/** DI */
implementation(deps.hilt.android)
implementation(project(":domain:legacy"))
kapt(deps.hilt.kapt)
/** Local storages */
/** Other deps */
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
}

View file

@ -0,0 +1,70 @@
package com.tangem.data.wallets
import androidx.core.text.isDigitsOnly
import com.tangem.blockchain.blockchains.near.NearWalletManager
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.repository.WalletAddressServiceRepository
import java.math.BigInteger
class DefaultWalletAddressServiceRepository(
private val walletManagersFacade: WalletManagersFacade,
) : WalletAddressServiceRepository {
override suspend fun validateAddress(userWalletId: UserWalletId, network: Network, address: String): Boolean {
val blockchain = Blockchain.fromId(network.id.value)
return if (blockchain.isNear()) {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
) ?: return false
(walletManager as? NearWalletManager)?.validateAddress(address) ?: false
} else {
blockchain.validateAddress(address)
}
}
override fun validateMemo(network: Network, memo: String): Boolean {
if (memo.isEmpty()) return true
return when (network.id.value) {
Blockchain.XRP.id -> {
val tag = memo.toLongOrNull()
tag != null && tag <= XRP_TAG_MAX_NUMBER
}
Blockchain.Stellar.id -> {
isAssignableXlmValue(memo)
}
else -> true
}
}
private fun Blockchain.isNear(): Boolean {
return this == Blockchain.Near || this == Blockchain.NearTestnet
}
private fun isAssignableXlmValue(value: String): Boolean {
return when {
value.isNotEmpty() && value.isDigitsOnly() -> {
try {
// from com.tangem.blockchain.blockchains.stellar.StellarMemo.toStellarSdkMemo
value.toBigInteger() in BigInteger.ZERO..Long.MAX_VALUE.toBigInteger() * 2.toBigInteger()
} catch (ex: NumberFormatException) {
false
}
}
else -> {
// from org.stellar.sdk.MemoText
value.toByteArray().size <= XLM_MEMO_MAX_LENGTH
}
}
}
companion object {
private const val XLM_MEMO_MAX_LENGTH = 28
private const val XRP_TAG_MAX_NUMBER = 4294967295
}
}

View file

@ -1,7 +1,10 @@
package com.tangem.data.wallets.di
import com.tangem.data.wallets.DefaultWalletAddressServiceRepository
import com.tangem.data.wallets.DefaultWalletsRepository
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.repository.WalletAddressServiceRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import dagger.Module
import dagger.Provides
@ -18,4 +21,12 @@ internal object WalletsDataModule {
fun providesWalletsRepository(appPreferencesStore: AppPreferencesStore): WalletsRepository {
return DefaultWalletsRepository(appPreferencesStore = appPreferencesStore)
}
@Provides
@Singleton
fun providesWalletAddressServiceRepository(
walletManagersFacade: WalletManagersFacade,
): WalletAddressServiceRepository {
return DefaultWalletAddressServiceRepository(walletManagersFacade)
}
}