Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-11 18:55:37 +03:00
parent 330ae73357
commit e7efe7b76a
4636 changed files with 234864 additions and 63507 deletions

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

@ -0,0 +1 @@
/build

View 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.analytics"
}
dependencies {
/** Project - Domain */
implementation(projects.domain.analytics)
implementation(projects.domain.wallets.models)
/** Project - Analytics */
implementation(projects.core.analytics.models)
/** Project - Data */
implementation(projects.core.datasource)
implementation(projects.data.common)
/** AndroidX */
implementation(deps.androidx.datastore)
/** DI */
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.kotlin.coroutines)
implementation(deps.timber)
}

View file

@ -0,0 +1,50 @@
package com.tangem.data.analytics
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectListSync
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.domain.analytics.model.WalletBalanceState
import com.tangem.domain.analytics.repository.AnalyticsRepository
import com.tangem.domain.wallets.models.UserWalletId
internal class DefaultAnalyticsRepository(
private val appPreferencesStore: AppPreferencesStore,
) : AnalyticsRepository {
override suspend fun checkIsEventSent(eventId: String): Boolean {
val sentEvents = appPreferencesStore
.getObjectListSync<String>(PreferencesKeys.SENT_ONE_TIME_EVENTS_KEY)
return eventId in sentEvents
}
override suspend fun setIsEventSent(eventId: String) {
appPreferencesStore.editData { mutablePreferences ->
val sentEvents = mutablePreferences.getObjectList<String>(PreferencesKeys.SENT_ONE_TIME_EVENTS_KEY)
val updatedSentEvents = sentEvents.orEmpty() + eventId
mutablePreferences.setObjectList(PreferencesKeys.SENT_ONE_TIME_EVENTS_KEY, updatedSentEvents)
}
}
override suspend fun getWalletBalanceState(userWalletId: UserWalletId): WalletBalanceState? {
val walletsBalanceState = appPreferencesStore.getObjectMapSync<WalletBalanceState>(
key = PreferencesKeys.WALLETS_BALANCES_STATES_KEY,
)
return walletsBalanceState[userWalletId.stringValue]
}
override suspend fun setWalletBalanceState(userWalletId: UserWalletId, balanceState: WalletBalanceState) {
appPreferencesStore.editData {
val walletsBalanceState = it.getObjectMap<WalletBalanceState>(
key = PreferencesKeys.WALLETS_BALANCES_STATES_KEY,
)
val updatedWalletsBalanceState = walletsBalanceState
.plus(pair = userWalletId.stringValue to balanceState)
it.setObjectMap(PreferencesKeys.WALLETS_BALANCES_STATES_KEY, updatedWalletsBalanceState)
}
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.data.analytics.di
import com.tangem.data.analytics.DefaultAnalyticsRepository
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.analytics.repository.AnalyticsRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal object AnalyticsDataModule {
@Provides
fun provideAnalyticsRepository(appPreferencesStore: AppPreferencesStore): AnalyticsRepository {
return DefaultAnalyticsRepository(appPreferencesStore)
}
}

1
data/app-currency/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,37 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.appcurrency"
}
dependencies {
/** Project - Domain */
implementation(projects.domain.core)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
/** Project - Data */
implementation(projects.core.datasource)
implementation(projects.data.common)
/** Project - Utils */
implementation(projects.core.utils)
/** AndroidX */
implementation(deps.androidx.datastore)
/** DI */
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
implementation(deps.timber)
}

View file

@ -0,0 +1,127 @@
package com.tangem.data.appcurrency
import com.tangem.data.appcurrency.utils.AppCurrencyConverter
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.local.appcurrency.AvailableAppCurrenciesStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.joda.time.Duration
internal class DefaultAppCurrencyRepository(
private val tangemTechApi: TangemTechApi,
private val appPreferencesStore: AppPreferencesStore,
private val availableAppCurrenciesStore: AvailableAppCurrenciesStore,
private val cacheRegistry: CacheRegistry,
private val dispatchers: CoroutineDispatcherProvider,
) : AppCurrencyRepository {
private val appCurrencyConverter = AppCurrencyConverter()
override fun getSelectedAppCurrency(): Flow<AppCurrency> {
return channelFlow {
launch {
appPreferencesStore
.getObject<CurrenciesResponse.Currency>(key = PreferencesKeys.SELECTED_APP_CURRENCY_KEY)
.filterNotNull()
.map(appCurrencyConverter::convert)
.collect(::send)
}
withContext(dispatchers.io) {
if (appPreferencesStore.getSyncOrNull(PreferencesKeys.SELECTED_APP_CURRENCY_KEY) == null) {
fetchDefaultAppCurrency()
}
}
}
}
override suspend fun getAvailableAppCurrencies(): List<AppCurrency> {
return withContext(dispatchers.io) {
fetchAvailableCurrenciesIfExpired()
val currencies = availableAppCurrenciesStore.getAllSyncOrNull()
?.map(appCurrencyConverter::convert)
?.sortedBy(AppCurrency::name)
requireNotNull(currencies) {
"No available currencies stored"
}
}
}
override suspend fun changeAppCurrency(currencyCode: String) {
withContext(dispatchers.io) {
val currency = requireNotNull(availableAppCurrenciesStore.getSyncOrNull(currencyCode)) {
"Unable to find app currency with provided code: $currencyCode"
}
appPreferencesStore.storeObject(
key = PreferencesKeys.SELECTED_APP_CURRENCY_KEY,
value = currency,
)
}
}
override suspend fun fetchDefaultAppCurrency(isRefresh: Boolean) {
withContext(dispatchers.io) {
fetchAvailableCurrenciesIfExpired(isRefresh)
val appCurrency = appPreferencesStore.getSyncOrNull(PreferencesKeys.SELECTED_APP_CURRENCY_KEY)
changeAppCurrency(appCurrency ?: DEFAULT_CURRENCY_CODE)
}
}
private suspend fun fetchAvailableCurrenciesIfExpired(isRefresh: Boolean = false) {
cacheRegistry.invokeOnExpire(
key = AVAILABLE_CURRENCIES_CACHE_KEY,
skipCache = isRefresh,
expireIn = Duration.standardMinutes(AVAILABLE_CURRENCIES_CACHE_KEY_EXPIRE_MINUTES),
block = { fetchAvailableCurrencies() },
)
}
private suspend fun fetchAvailableCurrencies() {
val response = safeApiCall(
call = { tangemTechApi.getCurrencyList().bind() },
onError = {
cacheRegistry.invalidate(AVAILABLE_CURRENCIES_CACHE_KEY)
getDefaultCurrenciesResponse()
},
)
availableAppCurrenciesStore.store(response)
}
private fun getDefaultCurrenciesResponse(): CurrenciesResponse = CurrenciesResponse(
currencies = listOf(
CurrenciesResponse.Currency(
id = DEFAULT_CURRENCY_CODE.lowercase(),
code = DEFAULT_CURRENCY_CODE,
name = "US Dollar",
unit = "$",
type = "fiat",
rateBTC = "",
),
),
)
private companion object {
const val AVAILABLE_CURRENCIES_CACHE_KEY = "available_currencies"
const val AVAILABLE_CURRENCIES_CACHE_KEY_EXPIRE_MINUTES = 15L
const val DEFAULT_CURRENCY_CODE = "USD"
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.data.appcurrency.di
import com.tangem.data.appcurrency.DefaultAppCurrencyRepository
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.appcurrency.AvailableAppCurrenciesStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
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 AppCurrencyDataModule {
@Provides
@Singleton
fun provideAppCurrencyRepository(
tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
availableAppCurrenciesStore: AvailableAppCurrenciesStore,
cacheRegistry: CacheRegistry,
dispatchers: CoroutineDispatcherProvider,
): AppCurrencyRepository {
return DefaultAppCurrencyRepository(
tangemTechApi = tangemTechApi,
appPreferencesStore = appPreferencesStore,
availableAppCurrenciesStore = availableAppCurrenciesStore,
cacheRegistry = cacheRegistry,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.appcurrency.utils
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.utils.converter.Converter
internal class AppCurrencyConverter : Converter<CurrenciesResponse.Currency, AppCurrency> {
override fun convert(value: CurrenciesResponse.Currency): AppCurrency {
return AppCurrency(
code = value.code,
name = value.name,
symbol = value.unit,
iconSmallUrl = value.iconSmallUrl,
iconMediumUrl = value.iconMediumUrl,
)
}
}

1
data/app-theme/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,33 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.apptheme"
}
dependencies {
/** Project - Domain */
implementation(projects.domain.appTheme)
implementation(projects.domain.appTheme.models)
/** Project - Data */
implementation(projects.core.datasource)
/** Project - Utils */
implementation(projects.core.utils)
/** DI */
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.kotlin.coroutines)
/** Local storages */
implementation(deps.androidx.datastore)
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.apptheme
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
import kotlinx.coroutines.flow.Flow
internal class DefaultAppThemeModeRepository(
private val appPreferencesStore: AppPreferencesStore,
) : AppThemeModeRepository {
override fun getAppThemeMode(): Flow<AppThemeMode> {
return appPreferencesStore.getObject(
key = PreferencesKeys.APP_THEME_MODE_KEY,
default = AppThemeMode.DEFAULT,
)
}
override suspend fun changeAppThemeMode(mode: AppThemeMode) {
appPreferencesStore.storeObject(key = PreferencesKeys.APP_THEME_MODE_KEY, value = mode)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.apptheme.di
import com.tangem.data.apptheme.DefaultAppThemeModeRepository
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
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 AppThemeModeDataModule {
@Provides
@Singleton
fun provideAppThemeModeRepository(appPreferencesStore: AppPreferencesStore): AppThemeModeRepository {
return DefaultAppThemeModeRepository(appPreferencesStore = appPreferencesStore)
}
}

1
data/balance-hiding/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,27 @@
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.balancehiding"
}
dependencies {
implementation(deps.androidx.datastore)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
implementation(deps.kotlin.coroutines)
implementation(projects.core.utils)
implementation(projects.core.datasource)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
}

View file

@ -0,0 +1,43 @@
package com.tangem.data.balancehiding
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrDefault
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.balancehiding.BalanceHidingSettings
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import kotlinx.coroutines.flow.Flow
internal class DefaultBalanceHidingRepository(
private val appPreferencesStore: AppPreferencesStore,
) : BalanceHidingRepository {
override var isUpdateEnabled: Boolean = true
override fun getBalanceHidingSettingsFlow(): Flow<BalanceHidingSettings> {
return appPreferencesStore.getObject(
key = PreferencesKeys.BALANCE_HIDING_SETTINGS_KEY,
default = DEFAULT_HIDING_SETTINGS,
)
}
override suspend fun storeBalanceHidingSettings(isBalanceHidden: BalanceHidingSettings) {
appPreferencesStore.storeObject(key = PreferencesKeys.BALANCE_HIDING_SETTINGS_KEY, value = isBalanceHidden)
}
override suspend fun getBalanceHidingSettings(): BalanceHidingSettings {
return appPreferencesStore.getObjectSyncOrDefault(
key = PreferencesKeys.BALANCE_HIDING_SETTINGS_KEY,
default = DEFAULT_HIDING_SETTINGS,
)
}
private companion object {
val DEFAULT_HIDING_SETTINGS = BalanceHidingSettings(
isHidingEnabledInSettings = false,
isBalanceHidden = false,
isBalanceHidingNotificationEnabled = true,
)
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.balancehiding
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager
import com.tangem.domain.balancehiding.DeviceFlipDetector
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
internal class DefaultDeviceFlipDetector(context: Context) : DeviceFlipDetector {
private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private var gravitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY)
override fun getDeviceFlipFlow(): Flow<Unit> = callbackFlow {
val listener = FlipListener { trySend(Unit) }
gravitySensor?.let {
sensorManager.registerListener(listener, it, SensorManager.SENSOR_DELAY_NORMAL)
}
awaitClose { sensorManager.unregisterListener(listener) }
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.data.balancehiding
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.os.SystemClock
internal class FlipListener(private val action: () -> Unit) : SensorEventListener {
private val zAxisThreshold = -6
private val throttleTimeMs = 3000
private var lastTriggerTime = 0L
private var isScreenDown = false
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
/* no-op */
}
override fun onSensorChanged(event: SensorEvent?) {
event?.let {
val currentTime = SystemClock.elapsedRealtime()
val zAxisValue = it.values[2]
if (zAxisValue < zAxisThreshold && !isScreenDown) {
isScreenDown = true
lastTriggerTime = currentTime
// TODO add module logging
// Timber.tag("onSensorChanged").d("screen down")
} else if (zAxisValue >= zAxisThreshold) {
if (isScreenDown && currentTime - lastTriggerTime <= throttleTimeMs) {
// Timber.tag("onSensorChanged").d("screen up!")
lastTriggerTime = currentTime
action.invoke()
}
isScreenDown = false
}
}
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.data.balancehiding.di
import android.content.Context
import com.tangem.data.balancehiding.DefaultBalanceHidingRepository
import com.tangem.data.balancehiding.DefaultDeviceFlipDetector
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.balancehiding.DeviceFlipDetector
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object BalanceHidingModule {
@Provides
@Singleton
fun provideBalanceHidingRepository(appPreferencesStore: AppPreferencesStore): BalanceHidingRepository {
return DefaultBalanceHidingRepository(appPreferencesStore = appPreferencesStore)
}
@Provides
@Singleton
fun provideFlipDetector(@ApplicationContext context: Context): DeviceFlipDetector {
return DefaultDeviceFlipDetector(context = context)
}
}

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

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,33 @@
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.card"
}
dependencies {
implementation(deps.androidx.datastore)
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
implementation(tangemDeps.card.android)
implementation(tangemDeps.card.core)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.libs.blockchainSdk)
implementation(projects.domain.card)
implementation(projects.domain.models)
}

View file

@ -0,0 +1,117 @@
package com.tangem.data.card
import androidx.datastore.preferences.core.MutablePreferences
import com.tangem.datasource.local.card.UsedCardInfo
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectList
import com.tangem.datasource.local.preferences.utils.getObjectListSync
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.domain.card.repository.CardRepository
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
internal class DefaultCardRepository(
private val appPreferencesStore: AppPreferencesStore,
) : CardRepository {
override fun wasCardScanned(cardId: String): Flow<Boolean> {
return appPreferencesStore.getObjectList<UsedCardInfo>(key = PreferencesKeys.USED_CARDS_INFO_KEY)
.map { savedCards ->
savedCards?.any { it.cardId == cardId } ?: false
}
}
override suspend fun setCardWasScanned(cardId: String) {
appPreferencesStore.editUsedCards(cardId) { it.copy(isScanned = true) }
}
override suspend fun startCardActivation(cardId: String) {
appPreferencesStore.editUsedCards(cardId) { it.copy(isActivationStarted = true) }
}
override suspend fun finishCardActivation(cardId: String) {
appPreferencesStore.editUsedCards(cardId) {
it.copy(isActivationStarted = true, isActivationFinished = true)
}
}
override suspend fun finishCardsActivation(cardIds: List<String>) {
appPreferencesStore.editData { mutablePreferences ->
val usedCards = mutablePreferences.getUsedCards()
val newCards = cardIds.mapNotNull { newCardId ->
if (usedCards.none { it.cardId == newCardId }) {
createDefaultUsedCardInfo(cardId = newCardId)
} else {
null
}
}
val updatedUsedCards = (usedCards + newCards).map { card ->
if (cardIds.contains(card.cardId)) {
card.copy(isActivationStarted = true, isActivationFinished = true)
} else {
card
}
}
mutablePreferences.setObjectList(key = PreferencesKeys.USED_CARDS_INFO_KEY, value = updatedUsedCards)
}
}
override suspend fun isActivationStarted(cardId: String): Boolean {
return getUsedCardSync(cardId)?.isActivationStarted ?: false
}
override suspend fun isActivationFinished(cardId: String): Boolean {
return getUsedCardSync(cardId)?.isActivationFinished ?: false
}
override suspend fun isActivationInProgress(cardId: String): Boolean {
val card = getUsedCardSync(cardId) ?: return false
return card.isActivationStarted && !card.isActivationFinished
}
override suspend fun isTangemTOSAccepted(): Boolean {
return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.IS_TANGEM_TOS_ACCEPTED_KEY, default = false)
}
override suspend fun acceptTangemTOS() {
return appPreferencesStore.store(key = PreferencesKeys.IS_TANGEM_TOS_ACCEPTED_KEY, true)
}
private suspend fun AppPreferencesStore.editUsedCards(cardId: String, update: (UsedCardInfo) -> UsedCardInfo) {
editData { mutablePreferences ->
val usedCards = mutablePreferences.getUsedCards()
val updatedUsedCards = usedCards.updateCard(cardId = cardId, update = update)
mutablePreferences.setObjectList(key = PreferencesKeys.USED_CARDS_INFO_KEY, value = updatedUsedCards)
}
}
private fun MutablePreferences.getUsedCards(): List<UsedCardInfo> {
return with(appPreferencesStore) {
getObjectListOrDefault(key = PreferencesKeys.USED_CARDS_INFO_KEY, default = mutableListOf())
}
}
private fun List<UsedCardInfo>.updateCard(
cardId: String,
update: (UsedCardInfo) -> UsedCardInfo,
): List<UsedCardInfo> {
val card = find { it.cardId == cardId } ?: createDefaultUsedCardInfo(cardId = cardId)
return addOrReplace(item = update(card), predicate = { it.cardId == cardId })
}
private suspend fun getUsedCardSync(cardId: String): UsedCardInfo? {
return appPreferencesStore.getObjectListSync<UsedCardInfo>(PreferencesKeys.USED_CARDS_INFO_KEY)
.firstOrNull { it.cardId == cardId }
}
private fun createDefaultUsedCardInfo(cardId: String) = UsedCardInfo(cardId = cardId)
}

View file

@ -0,0 +1,73 @@
package com.tangem.data.card
import com.tangem.TangemSdk
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.common.UserCodeType
import com.tangem.common.core.CardIdDisplayFormat
import com.tangem.common.core.UserCodeRequestPolicy
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.models.scan.ProductType
/**
* Implementation of repository for managing of CardSDK config
*
* @property cardSdkProvider CardSDK instance provider
*
[REDACTED_AUTHOR]
*/
internal class DefaultCardSdkConfigRepository(
private val cardSdkProvider: CardSdkProvider,
private val transactionSignerFactory: TransactionSignerFactory,
) : CardSdkConfigRepository {
@Deprecated("Use CardSdkConfigRepository's methods instead of this property")
override val sdk: TangemSdk
get() = cardSdkProvider.sdk
override var isBiometricsRequestPolicy: Boolean
get() = sdk.config.userCodeRequestPolicy is UserCodeRequestPolicy.AlwaysWithBiometrics
set(value) {
sdk.config.userCodeRequestPolicy = if (value) {
UserCodeRequestPolicy.AlwaysWithBiometrics(codeType = UserCodeType.AccessCode)
} else {
UserCodeRequestPolicy.Default
}
}
override fun setAccessCodeRequestPolicy(isBiometricsRequestPolicy: Boolean) {
sdk.config.userCodeRequestPolicy = if (isBiometricsRequestPolicy) {
UserCodeRequestPolicy.AlwaysWithBiometrics(codeType = UserCodeType.AccessCode)
} else {
UserCodeRequestPolicy.Default
}
}
override fun resetCardIdDisplayFormat() {
sdk.config.cardIdDisplayFormat = CardIdDisplayFormat.Full
}
override fun updateCardIdDisplayFormat(productType: ProductType) {
sdk.config.cardIdDisplayFormat = when (productType) {
ProductType.Twins -> CardIdDisplayFormat.LastLuhn(numbers = 4)
ProductType.Note,
ProductType.Wallet,
ProductType.Wallet2,
ProductType.Start2Coin,
ProductType.Ring,
ProductType.Visa,
-> CardIdDisplayFormat.Full
}
}
override fun getCommonSigner(cardId: String?, twinKey: TwinKey?): TransactionSigner {
return transactionSignerFactory.createTransactionSigner(cardId = cardId, sdk = sdk, twinKey = twinKey)
}
override fun isLinkedTerminal() = sdk.config.linkedTerminal
override fun setLinkedTerminal(isLinked: Boolean?) {
sdk.config.linkedTerminal = isLinked
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.data.card
import com.tangem.TangemSdk
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.domain.card.models.TwinKey
/**
[REDACTED_AUTHOR]
*/
interface TransactionSignerFactory {
fun createTransactionSigner(cardId: String?, sdk: TangemSdk, twinKey: TwinKey?): TransactionSigner
}

View file

@ -0,0 +1,37 @@
package com.tangem.data.card.di
import com.tangem.data.card.TransactionSignerFactory
import com.tangem.data.card.DefaultCardRepository
import com.tangem.data.card.DefaultCardSdkConfigRepository
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.card.repository.CardSdkConfigRepository
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 CardDataModule {
@Provides
@Singleton
fun provideCardSdkConfigRepository(
cardSdkProvider: CardSdkProvider,
transactionSignerFactory: TransactionSignerFactory,
): CardSdkConfigRepository {
return DefaultCardSdkConfigRepository(
cardSdkProvider = cardSdkProvider,
transactionSignerFactory = transactionSignerFactory,
)
}
@Provides
@Singleton
fun provideCardRepository(appPreferencesStore: AppPreferencesStore): CardRepository {
return DefaultCardRepository(appPreferencesStore = appPreferencesStore)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.data.card.sdk
import androidx.fragment.app.FragmentActivity
/**
* Lifecycle observer for creating Card SDK instance
*
[REDACTED_AUTHOR]
*/
interface CardSdkOwner {
fun register(activity: FragmentActivity)
}

View file

@ -0,0 +1,14 @@
package com.tangem.data.card.sdk
import com.tangem.TangemSdk
/**
* CardSDK instance provider
*
[REDACTED_AUTHOR]
*/
interface CardSdkProvider {
/** CardSDK instance */
val sdk: TangemSdk
}

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

@ -0,0 +1 @@
/build

View 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)
}

View file

@ -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)
},
)

View 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
}
}

View 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
}
}
}
}

View 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)
}
}

View file

@ -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,
)
}

View file

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

View file

@ -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),
)
}
}

View file

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

View file

@ -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,
)
}
}

View file

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

View file

@ -0,0 +1,13 @@
package com.tangem.data.common.locale
import java.util.Locale
/**
[REDACTED_AUTHOR]
*/
interface LocaleProvider {
fun getLocale(): Locale
fun getWebUriLocaleLanguage(): String
}

View file

@ -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()
}
}

View file

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

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

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,46 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.feedback"
}
dependencies {
// region AndroidX libraries
implementation(deps.androidx.datastore)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Tangem libraries
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
// endregion
// Other libraries
implementation(deps.timber)
// endregion
// region Core modules
implementation(projects.core.datasource)
implementation(projects.core.navigation)
implementation(projects.core.utils)
// endregion
implementation(projects.domain.feedback)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.libs.blockchainSdk)
}

View file

@ -0,0 +1,107 @@
package com.tangem.data.feedback
import android.os.Build
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.navigation.email.EmailSender
import com.tangem.data.feedback.converters.BlockchainInfoConverter
import com.tangem.data.feedback.converters.CardInfoConverter
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.feedback.models.*
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.version.AppVersionProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import timber.log.Timber
import java.io.File
/**
* Implementation of [FeedbackRepository]
*
* @property appLogsStore app logs store
* @property userWalletsListManager user wallets list manager
* @property walletManagersStore wallet managers store
* @property emailSender email sender
* @property appVersionProvider app version provider
*
[REDACTED_AUTHOR]
*/
internal class DefaultFeedbackRepository(
private val appLogsStore: AppLogsStore,
private val userWalletsListManager: UserWalletsListManager,
private val walletManagersStore: WalletManagersStore,
private val emailSender: EmailSender,
private val appVersionProvider: AppVersionProvider,
) : FeedbackRepository {
private val blockchainsErrors = MutableStateFlow<Map<UserWalletId, BlockchainErrorInfo>>(emptyMap())
override fun getCardInfo(scanResponse: ScanResponse) = CardInfoConverter.convert(value = scanResponse)
override fun getUserWalletsInfo(userWalletId: UserWalletId?): UserWalletsInfo {
return UserWalletsInfo(
selectedUserWalletId = userWalletId?.stringValue ?: "card isn't activated",
totalUserWallets = userWalletsListManager.walletsCount,
)
}
override suspend fun getBlockchainInfoList(userWalletId: UserWalletId): List<BlockchainInfo> {
return walletManagersStore
.getAllSync(userWalletId = userWalletId)
.map(BlockchainInfoConverter::convert)
}
override suspend fun getBlockchainInfo(
userWalletId: UserWalletId,
blockchainId: String,
derivationPath: String?,
): BlockchainInfo? {
return walletManagersStore
.getSyncOrNull(
userWalletId = userWalletId,
blockchain = Blockchain.fromId(blockchainId),
derivationPath = derivationPath,
)
?.let(BlockchainInfoConverter::convert)
}
override fun getPhoneInfo(): PhoneInfo {
return PhoneInfo(
phoneModel = Build.MODEL,
osVersion = Build.VERSION.SDK_INT.toString(),
appVersion = appVersionProvider.versionName,
)
}
override fun saveBlockchainErrorInfo(error: BlockchainErrorInfo) {
val userWallet = userWalletsListManager.selectedUserWalletSync ?: error("UserWallet is not selected")
blockchainsErrors.update {
it.toMutableMap().apply {
put(userWallet.walletId, error)
}
}
}
override fun getBlockchainErrorInfo(userWalletId: UserWalletId): BlockchainErrorInfo? {
return blockchainsErrors.value[userWalletId].also {
if (it == null) Timber.e("Blockchain error info is null for $userWalletId")
}
}
override fun getLogFile(): File? = appLogsStore.getFile()
override fun sendEmail(feedbackEmail: FeedbackEmail) {
emailSender.send(
EmailSender.Email(
address = feedbackEmail.address,
subject = feedbackEmail.subject,
message = feedbackEmail.message,
attachment = feedbackEmail.file,
),
)
}
}

View file

@ -0,0 +1,48 @@
package com.tangem.data.feedback.converters
import com.tangem.blockchain.common.Wallet
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.address.Address
import com.tangem.domain.feedback.models.BlockchainInfo
import com.tangem.domain.feedback.models.BlockchainInfo.Addresses.Multiple.AddressInfo
import com.tangem.utils.converter.Converter
import com.tangem.domain.feedback.models.BlockchainInfo.Addresses as BlockchainAddresses
/**
* Converter from [WalletManager] to [BlockchainInfo]
*
[REDACTED_AUTHOR]
*/
internal object BlockchainInfoConverter : Converter<WalletManager, BlockchainInfo> {
override fun convert(value: WalletManager): BlockchainInfo {
return BlockchainInfo(
blockchain = value.wallet.blockchain.fullName,
derivationPath = value.wallet.publicKey.derivationPath?.rawPath ?: "",
outputsCount = value.outputsCount?.toString(),
host = value.currentHost,
addresses = value.wallet.mapAddresses(Address::value),
explorerLinks = value.wallet.mapAddresses { value.wallet.getExploreUrl(it.value) },
tokens = value.cardTokens.map { token ->
BlockchainInfo.TokenInfo(
id = token.id,
name = token.name,
contractAddress = token.contractAddress,
decimals = token.decimals.toString(),
)
},
)
}
private inline fun Wallet.mapAddresses(map: (Address) -> String): BlockchainAddresses {
return if (addresses.size == 1) {
BlockchainAddresses.Single(value = map(addresses.first()))
} else {
BlockchainAddresses.Multiple(
addresses.map {
AddressInfo(type = it.type.name, value = map(it))
},
)
}
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.data.feedback.converters
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.common.util.getBackupCardsCount
import com.tangem.domain.feedback.models.CardInfo
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.converter.Converter
/**
* Converter from [ScanResponse] to [CardInfo]
*
[REDACTED_AUTHOR]
*/
internal object CardInfoConverter : Converter<ScanResponse, CardInfo> {
override fun convert(value: ScanResponse): CardInfo {
return with(value) {
CardInfo(
userWalletId = createUserWalletId(scanResponse = value),
cardId = card.cardId,
cardsCount = value.getBackupCardsCount()?.toString() ?: "0",
firmwareVersion = card.firmwareVersion.stringValue,
cardBlockchain = walletData?.blockchain,
signedHashesList = card.wallets.map {
CardInfo.SignedHashes(curve = it.curve.curve, total = it.totalSignedHashes?.toString())
},
isImported = value.card.wallets.any(CardDTO.Wallet::isImported),
isStart2Coin = value.card.isStart2Coin,
)
}
}
private fun createUserWalletId(scanResponse: ScanResponse): UserWalletId? {
return UserWalletIdBuilder.scanResponse(scanResponse = scanResponse).build()
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.data.feedback.di
import com.tangem.core.navigation.email.EmailSender
import com.tangem.data.feedback.DefaultFeedbackRepository
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.utils.version.AppVersionProvider
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 FeedbackRepositoryModule {
@Provides
@Singleton
fun provideFeedbackRepository(
appLogsStore: AppLogsStore,
userWalletsListManager: UserWalletsListManager,
walletManagersStore: WalletManagersStore,
emailSender: EmailSender,
appVersionProvider: AppVersionProvider,
): FeedbackRepository {
return DefaultFeedbackRepository(
appLogsStore = appLogsStore,
userWalletsListManager = userWalletsListManager,
walletManagersStore = walletManagersStore,
emailSender = emailSender,
appVersionProvider = appVersionProvider,
)
}
}

1
data/manage-tokens/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,51 @@
import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.example.data.managetokens"
}
dependencies {
/** Project - Domain */
implementation(projects.domain.demo)
implementation(projects.domain.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.legacy)
/** Project - Data */
implementation(projects.core.datasource)
implementation(projects.data.common)
implementation(projects.data.tokens)
/** Project - Utils */
implementation(projects.core.utils)
implementation(projects.core.pagination)
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
/** Tangem SDKs */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
/** AndroidX */
implementation(deps.androidx.datastore)
/** DI */
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.moshi.kotlin)
implementation(deps.timber)
kaptForObfuscatingVariants(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
}

View file

@ -0,0 +1,284 @@
package com.tangem.data.managetokens
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.getNetwork
import com.tangem.data.managetokens.utils.TokenAddressesConverter
import com.tangem.data.tokens.utils.UserTokensBackwardCompatibility
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.extensions.canHandleBlockchain
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.managetokens.model.AddCustomTokenForm
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.managetokens.repository.CustomTokensRepository
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
internal class DefaultCustomTokensRepository(
private val tangemTechApi: TangemTechApi,
private val userWalletsStore: UserWalletsStore,
private val appPreferencesStore: AppPreferencesStore,
private val walletManagersFacade: WalletManagersFacade,
private val excludedBlockchains: ExcludedBlockchains,
private val dispatchers: CoroutineDispatcherProvider,
) : CustomTokensRepository {
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
private val userTokensResponseFactory = UserTokensResponseFactory()
private val tokenAddressConverter = TokenAddressesConverter()
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
override suspend fun validateContractAddress(contractAddress: String, networkId: Network.ID): Boolean =
withContext(dispatchers.io) {
when (val blockchain = Blockchain.fromId(networkId.value)) {
Blockchain.Unknown,
Blockchain.Binance,
Blockchain.BinanceTestnet,
Blockchain.Kaspa,
-> true
Blockchain.Cardano -> blockchain.validateContractAddress(contractAddress)
else -> blockchain.validateAddress(contractAddress)
}
}
override suspend fun isCurrencyNotAdded(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
contractAddress: String?,
): Boolean {
return withContext(dispatchers.io) {
val storedCurrencies = appPreferencesStore.getObjectSyncOrNull<UserTokensResponse>(
key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue),
)
requireNotNull(storedCurrencies) {
"User tokens not found for user wallet [$userWalletId] while checking if currency is not added"
}
storedCurrencies.tokens.none { token ->
Blockchain.fromId(networkId.value).toNetworkId() == token.networkId &&
derivationPath.value == token.derivationPath &&
contractAddress.equals(token.contractAddress, ignoreCase = true)
}
}
}
override suspend fun findToken(
userWalletId: UserWalletId,
contractAddress: String,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): CryptoCurrency.Token? = withContext(dispatchers.io) {
val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"User wallet [$userWalletId] not found while finding token"
}
val network = requireNotNull(
getNetwork(networkId, derivationPath, userWallet.scanResponse, excludedBlockchains),
) {
"Network [$networkId] not found while finding token"
}
val tokenAddress = tokenAddressConverter.convertTokenAddress(
networkId,
contractAddress,
symbol = null,
)
val supportedTokenNetworkIds = userWallet.scanResponse.card
.supportedBlockchains(userWallet.scanResponse.cardTypesResolver, excludedBlockchains)
.filter(Blockchain::canHandleTokens)
.map(Blockchain::toNetworkId)
val response = tangemTechApi.getCoins(
contractAddress = contractAddress,
networkIds = network.backendId,
active = true,
).getOrThrow()
response.coins.firstNotNullOfOrNull { coin ->
val coinNetwork = coin.networks.firstOrNull { network ->
(network.contractAddress != null || network.decimalCount != null) &&
network.contractAddress.equals(tokenAddress, ignoreCase = true) &&
network.networkId in supportedTokenNetworkIds
}
if (coinNetwork != null) {
cryptoCurrencyFactory.createToken(
network = network,
rawId = CryptoCurrency.RawID(coin.id),
name = coin.name,
symbol = coin.symbol,
decimals = coinNetwork.decimalCount!!.toInt(),
contractAddress = tokenAddress,
)
} else {
null
}
}
}
override suspend fun createCoin(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): CryptoCurrency.Coin {
val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"User wallet [$userWalletId] not found while creating coin"
}
val network = requireNotNull(
getNetwork(networkId, derivationPath, userWallet.scanResponse, excludedBlockchains),
) {
"Network [$networkId] not found while creating coin"
}
return cryptoCurrencyFactory.createCoin(network)
}
override suspend fun createToken(
managedCryptoCurrency: ManagedCryptoCurrency.Token,
sourceNetwork: ManagedCryptoCurrency.SourceNetwork.Default,
rawId: CryptoCurrency.RawID?,
): CryptoCurrency.Token {
return cryptoCurrencyFactory.createToken(
network = sourceNetwork.network,
rawId = rawId,
name = managedCryptoCurrency.name,
symbol = managedCryptoCurrency.symbol,
decimals = sourceNetwork.decimals,
contractAddress = sourceNetwork.contractAddress,
)
}
override suspend fun createCustomToken(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
formValues: AddCustomTokenForm.Validated.All,
): CryptoCurrency.Token {
val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"User wallet [$userWalletId] not found while creating custom token"
}
val network = requireNotNull(
getNetwork(networkId, derivationPath, userWallet.scanResponse, excludedBlockchains),
) {
"Network [$networkId] not found while creating custom token"
}
val tokenAddress = tokenAddressConverter.convertTokenAddress(
networkId,
formValues.contractAddress,
formValues.symbol,
)
return cryptoCurrencyFactory.createToken(
network = network,
rawId = null,
name = formValues.name,
symbol = formValues.symbol,
decimals = formValues.decimals,
contractAddress = tokenAddress,
)
}
override suspend fun removeCurrency(userWalletId: UserWalletId, currency: ManagedCryptoCurrency.Custom) =
withContext(dispatchers.io) {
val cryptoCurrency = when (currency) {
is ManagedCryptoCurrency.Custom.Coin -> createCoin(
userWalletId = userWalletId,
networkId = currency.network.id,
derivationPath = currency.network.derivationPath,
)
is ManagedCryptoCurrency.Custom.Token -> cryptoCurrencyFactory.createToken(
network = currency.network,
rawId = currency.currencyId.rawCurrencyId,
name = currency.name,
symbol = currency.symbol,
decimals = currency.decimals,
contractAddress = currency.contractAddress,
)
}
val storedCurrencies = appPreferencesStore.getObjectSyncOrNull<UserTokensResponse>(
key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue),
)
requireNotNull(storedCurrencies) {
"User tokens not found for user wallet [$userWalletId] while removing currency"
}
val token = userTokensResponseFactory.createResponseToken(cryptoCurrency)
storeAndPushTokens(
userWalletId = userWalletId,
response = storedCurrencies.copy(tokens = storedCurrencies.tokens.filterNot { it == token }),
)
when (cryptoCurrency) {
is CryptoCurrency.Coin -> walletManagersFacade.remove(userWalletId, setOf(cryptoCurrency.network))
is CryptoCurrency.Token -> walletManagersFacade.removeTokens(userWalletId, setOf(cryptoCurrency))
}
}
override suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> = withContext(dispatchers.io) {
val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"User wallet [$userWalletId] not found while getting supported networks"
}
val scanResponse = userWallet.scanResponse
Blockchain.entries
.mapNotNull { blockchain ->
val canHandleBlockchain = scanResponse.card.canHandleBlockchain(
blockchain,
scanResponse.cardTypesResolver,
excludedBlockchains,
)
if (canHandleBlockchain) {
getNetwork(
blockchain = blockchain,
extraDerivationPath = null,
scanResponse = scanResponse,
excludedBlockchains = excludedBlockchains,
)
} else {
null
}
}
}
override fun createDerivationPath(rawPath: String): Network.DerivationPath {
val sdkPath = DerivationPath(rawPath)
return Network.DerivationPath.Custom(
value = sdkPath.rawPath,
)
}
private suspend fun storeAndPushTokens(userWalletId: UserWalletId, response: UserTokensResponse) {
val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response)
appPreferencesStore.storeObject(
key = PreferencesKeys.getUserTokensKey(userWalletId = userWalletId.stringValue),
value = compatibleUserTokensResponse,
)
pushTokens(userWalletId, response)
}
private suspend fun pushTokens(userWalletId: UserWalletId, response: UserTokensResponse) {
safeApiCall({ tangemTechApi.saveUserTokens(userWalletId.stringValue, response).bind() }) {
Timber.e(it, "Unable to push user tokens for: ${userWalletId.stringValue}")
}
}
}

View file

@ -0,0 +1,325 @@
package com.tangem.data.managetokens
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.compatibility.l2BlockchainsCoinIds
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.getBlockchain
import com.tangem.data.common.utils.retryOnError
import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher
import com.tangem.data.managetokens.utils.ManagedCryptoCurrencyFactory
import com.tangem.data.tokens.utils.CardCryptoCurrenciesFactory
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.config.testnet.TestnetTokensStorage
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.common.extensions.canHandleBlockchain
import com.tangem.domain.common.extensions.canHandleToken
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.domain.common.extensions.supportedTokens
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.managetokens.model.*
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork
import com.tangem.domain.managetokens.repository.ManageTokensRepository
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListSource
import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher
import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher.Request
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@Suppress("LongParameterList")
internal class DefaultManageTokensRepository(
private val tangemTechApi: TangemTechApi,
private val userWalletsStore: UserWalletsStore,
private val manageTokensUpdateFetcher: ManageTokensUpdateFetcher,
private val appPreferencesStore: AppPreferencesStore,
private val testnetTokensStorage: TestnetTokensStorage,
private val excludedBlockchains: ExcludedBlockchains,
private val dispatchers: CoroutineDispatcherProvider,
) : ManageTokensRepository {
private val managedCryptoCurrencyFactory = ManagedCryptoCurrencyFactory(excludedBlockchains)
private val userTokensResponseFactory = UserTokensResponseFactory()
private val cardCurrenciesFactory = CardCryptoCurrenciesFactory(DemoConfig(), excludedBlockchains)
// region getTokenListBatchFlow
override fun getTokenListBatchFlow(
context: ManageTokensListBatchingContext,
loadUserTokensFromRemote: Boolean,
batchSize: Int,
): ManageTokensListBatchFlow {
return BatchListSource(
fetchDispatcher = dispatchers.io,
context = context,
generateNewKey = { it.size.inc() },
batchFetcher = createFetcher(batchSize, loadUserTokensFromRemote),
updateFetcher = manageTokensUpdateFetcher,
).toBatchFlow()
}
private fun createFetcher(
batchSize: Int,
loadUserTokensFromRemote: Boolean,
): LimitOffsetBatchFetcher<ManageTokensListConfig, List<ManagedCryptoCurrency>> = LimitOffsetBatchFetcher(
prefetchDistance = batchSize,
batchSize = batchSize,
subFetcher = { request, _, isFirstBatchFetching ->
val userWallet = request.params.userWalletId?.let { getUserWallet(it) }
if (userWallet?.scanResponse?.card?.isTestCard == true) {
fetchTestnetCurrencies(userWallet, request)
} else {
fetchCurrencies(
userWallet = userWallet,
request = request,
isFirstBatchFetching = isFirstBatchFetching,
loadUserTokensFromRemote = loadUserTokensFromRemote,
)
}
},
)
@Suppress("ComplexCondition")
private suspend fun fetchCurrencies(
userWallet: UserWallet?,
request: Request<ManageTokensListConfig>,
isFirstBatchFetching: Boolean,
loadUserTokensFromRemote: Boolean,
): BatchFetchResult.Success<List<ManagedCryptoCurrency>> {
val supportedBlockchains = getSupportedBlockchains(userWallet)
val query = request.params.searchText.takeUnless(String?::isNullOrBlank)
val call = suspend {
tangemTechApi.getCoins(
networkIds = supportedBlockchains.joinToString(
separator = ",",
transform = Blockchain::toNetworkId,
),
active = true,
searchText = query,
offset = request.offset,
limit = request.limit,
).getOrThrow()
}
val coinsResponse = if (isFirstBatchFetching) {
call()
} else {
retryOnError(call = call)
}
// filter l2 coins
val updatedCoinsResponse = coinsResponse.copy(
coins = coinsResponse.coins.filterNot { l2BlockchainsCoinIds.contains(it.id) },
)
val tokensResponse = request.params.userWalletId?.let { userWalletId ->
if (loadUserTokensFromRemote && userWallet != null) {
safeApiCall({ tangemTechApi.getUserTokens(userWalletId.stringValue).bind() }) {
createDefaultUserTokensResponse(userWallet)
}
} else {
getSavedUserTokensResponseSync(userWalletId)
}
}
val items = if (isFirstBatchFetching &&
tokensResponse != null &&
userWallet != null &&
query == null
) {
managedCryptoCurrencyFactory.createWithCustomTokens(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
scanResponse = userWallet.scanResponse,
)
} else {
managedCryptoCurrencyFactory.create(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
scanResponse = userWallet?.scanResponse,
)
}
return BatchFetchResult.Success(
data = items,
empty = items.isEmpty(),
last = items.size < request.limit,
)
}
private suspend fun fetchTestnetCurrencies(
userWallet: UserWallet,
request: Request<ManageTokensListConfig>,
): BatchFetchResult.Success<List<ManagedCryptoCurrency>> {
val searchText = request.params.searchText
val testnetTokensConfig = testnetTokensStorage.getConfig()
val items = managedCryptoCurrencyFactory.createTestnetWithCustomTokens(
testnetTokensConfig = if (!searchText.isNullOrBlank()) {
testnetTokensConfig.copy(
tokens = testnetTokensConfig.tokens.filter { token ->
token.symbol.contains(other = searchText, ignoreCase = true) ||
token.name.contains(other = searchText, ignoreCase = true)
},
)
} else {
testnetTokensConfig
},
tokensResponse = getSavedUserTokensResponseSync(userWallet.walletId),
scanResponse = userWallet.scanResponse,
)
return BatchFetchResult.Success(
data = items,
empty = items.isEmpty(),
last = true,
)
}
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"
}
}
private fun getSupportedBlockchains(userWallet: UserWallet?): List<Blockchain> {
return userWallet?.scanResponse?.let {
it.card.supportedBlockchains(it.cardTypesResolver, excludedBlockchains)
} ?: Blockchain.entries.filter {
!it.isTestnet() && it !in excludedBlockchains
}
}
// endregion
override suspend fun hasLinkedTokens(
userWalletId: UserWalletId,
network: Network,
tempAddedTokens: Map<ManagedCryptoCurrency.Token, Set<Network>>,
tempRemovedTokens: Map<ManagedCryptoCurrency.Token, Set<Network>>,
): Boolean {
val addedTokens = tempAddedTokens.mapToResponseTokens()
val removedTokens = tempRemovedTokens.mapToResponseTokens()
val storedTokens = requireNotNull(
value = getSavedUserTokensResponseSync(userWalletId),
lazyMessage = { "Unable to find tokens response for user wallet with provided ID: $userWalletId" },
)
val newTokensList = storedTokens.tokens + addedTokens - removedTokens.toSet()
return newTokensList.any {
it.contractAddress != null &&
it.networkId == network.backendId &&
it.derivationPath == network.derivationPath.value
}
}
private fun Map<ManagedCryptoCurrency.Token, Set<Network>>.mapToResponseTokens(): List<UserTokensResponse.Token> {
return flatMap { (token, networks) ->
token.availableNetworks
.filter { sourceNetwork -> networks.contains(sourceNetwork.network) }
.map { sourceNetwork ->
val blockchain = getBlockchain(sourceNetwork.network.id)
UserTokensResponse.Token(
id = token.id.value,
networkId = blockchain.toNetworkId(),
derivationPath = sourceNetwork.network.derivationPath.value,
name = token.name,
symbol = token.symbol,
decimals = sourceNetwork.decimals,
contractAddress = (sourceNetwork as? SourceNetwork.Default)?.contractAddress,
)
}
}
}
private suspend fun getSavedUserTokensResponseSync(key: UserWalletId): UserTokensResponse? {
return appPreferencesStore.getObjectSyncOrNull<UserTokensResponse>(
key = PreferencesKeys.getUserTokensKey(key.stringValue),
)
}
override suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
sourceNetwork: SourceNetwork,
): CurrencyUnsupportedState? {
val userWallet = getUserWallet(userWalletId = userWalletId)
val blockchain = getBlockchain(sourceNetwork.id)
return when (sourceNetwork) {
is SourceNetwork.Default -> checkTokenUnsupportedState(userWallet = userWallet, blockchain = blockchain)
is SourceNetwork.Main -> checkBlockchainUnsupportedState(userWallet = userWallet, blockchain = blockchain)
}
}
override suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
rawNetworkId: String,
isMainNetwork: Boolean,
): CurrencyUnsupportedState? {
val userWallet = getUserWallet(userWalletId = userWalletId)
val blockchain = Blockchain.fromNetworkId(networkId = rawNetworkId)
?: error("Can not create blockchain with given networkId -> $rawNetworkId")
return if (isMainNetwork) {
checkBlockchainUnsupportedState(userWallet, blockchain)
} else {
checkTokenUnsupportedState(userWallet, blockchain)
}
}
private fun checkBlockchainUnsupportedState(
userWallet: UserWallet,
blockchain: Blockchain,
): CurrencyUnsupportedState? {
val canHandleBlockchain = userWallet.scanResponse.card.canHandleBlockchain(
blockchain = blockchain,
cardTypesResolver = userWallet.cardTypesResolver,
excludedBlockchains = excludedBlockchains,
)
return if (!canHandleBlockchain) {
CurrencyUnsupportedState.UnsupportedNetwork(networkName = blockchain.fullName)
} else {
null
}
}
private fun checkTokenUnsupportedState(
userWallet: UserWallet,
blockchain: Blockchain,
): CurrencyUnsupportedState.Token? {
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
val supportedTokens = userWallet.scanResponse.card.supportedTokens(
cardTypesResolver,
excludedBlockchains,
)
return when {
// refactor this later by moving all this logic in card config
blockchain == Blockchain.Solana && !supportedTokens.contains(Blockchain.Solana) -> {
CurrencyUnsupportedState.Token.NetworkTokensUnsupported(networkName = blockchain.fullName)
}
!userWallet.scanResponse.card.canHandleToken(supportedTokens, blockchain, cardTypesResolver) -> {
CurrencyUnsupportedState.Token.UnsupportedCurve(networkName = blockchain.fullName)
}
else -> null
}
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.data.managetokens.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.managetokens.DefaultCustomTokensRepository
import com.tangem.data.managetokens.DefaultManageTokensRepository
import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.config.testnet.TestnetTokensStorage
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.managetokens.repository.CustomTokensRepository
import com.tangem.domain.managetokens.repository.ManageTokensRepository
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 ManageTokensDataModule {
@Provides
@Singleton
fun provideManageTokensRepository(
tangemTechApi: TangemTechApi,
userWalletsStore: UserWalletsStore,
manageTokensUpdateFetcher: ManageTokensUpdateFetcher,
appPreferencesStore: AppPreferencesStore,
testnetTokensStorage: TestnetTokensStorage,
dispatchers: CoroutineDispatcherProvider,
excludedBlockchains: ExcludedBlockchains,
): ManageTokensRepository {
return DefaultManageTokensRepository(
tangemTechApi,
userWalletsStore,
manageTokensUpdateFetcher,
appPreferencesStore,
testnetTokensStorage,
excludedBlockchains,
dispatchers,
)
}
@Provides
@Singleton
fun provideCustomTokensRepository(
tangemTechApi: TangemTechApi,
userWalletsStore: UserWalletsStore,
appPreferencesStore: AppPreferencesStore,
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
excludedBlockchains: ExcludedBlockchains,
): CustomTokensRepository {
return DefaultCustomTokensRepository(
tangemTechApi,
userWalletsStore,
appPreferencesStore,
walletManagersFacade,
excludedBlockchains,
dispatchers,
)
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.data.managetokens.utils
import com.tangem.domain.managetokens.model.ManageTokensUpdateAction
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchUpdateFetcher
import com.tangem.pagination.BatchUpdateResult
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class ManageTokensUpdateFetcher @Inject constructor() :
BatchUpdateFetcher<Int, List<ManagedCryptoCurrency>, ManageTokensUpdateAction> {
override suspend fun BatchUpdateFetcher.UpdateContext<Int, List<ManagedCryptoCurrency>>.fetchUpdateAsync(
toUpdate: List<Batch<Int, List<ManagedCryptoCurrency>>>,
updateRequest: ManageTokensUpdateAction,
) {
when (updateRequest) {
is ManageTokensUpdateAction.AddCurrency -> coroutineScope {
val tasks = toUpdate.map { (key, data) ->
async {
val currencyIndex = data.indexOfFirst { it.id == updateRequest.currencyId }
.takeIf { it != -1 }
?: error("Currency '${updateRequest.currencyId}' not found in batch #$key")
val updatedCurrency = when (val currency = data[currencyIndex]) {
is ManagedCryptoCurrency.Custom -> error("Can't add custom currency '${currency.id}'")
is ManagedCryptoCurrency.Token -> {
currency.copy(
addedIn = if (updateRequest.isSelected) {
currency.addedIn + updateRequest.network
} else {
currency.addedIn - updateRequest.network
},
)
}
}
data.toMutableList().apply {
set(currencyIndex, updatedCurrency)
}
}
}
tasks.forEachIndexed { index, task ->
launch {
val updatedItems = task.await()
update {
BatchUpdateResult.Success(
data = mapNotNull {
if (it.key == toUpdate[index].key) {
Batch(it.key, updatedItems)
} else {
null
}
},
)
}
}
}
}
}
}
}

View file

@ -0,0 +1,247 @@
package com.tangem.data.managetokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.compatibility.applyL2Compatibility
import com.tangem.blockchainsdk.compatibility.getL2CompatibilityTokenComparison
import com.tangem.blockchainsdk.compatibility.l2BlockchainsList
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.data.common.currency.getCoinId
import com.tangem.data.common.currency.getNetwork
import com.tangem.data.common.currency.getTokenId
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.config.testnet.models.TestnetTokensConfig
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.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
internal class ManagedCryptoCurrencyFactory(
private val excludedBlockchains: ExcludedBlockchains,
) {
fun create(
coinsResponse: CoinsResponse,
tokensResponse: UserTokensResponse?,
scanResponse: ScanResponse?,
): List<ManagedCryptoCurrency> {
return coinsResponse.coins.mapNotNull { coin ->
createToken(coin, tokensResponse, coinsResponse.imageHost, scanResponse)
}
}
fun createWithCustomTokens(
coinsResponse: CoinsResponse,
tokensResponse: UserTokensResponse,
scanResponse: ScanResponse,
): List<ManagedCryptoCurrency> {
val customTokens = createCustomTokens(tokensResponse, scanResponse)
val tokens = create(coinsResponse, tokensResponse, scanResponse)
return customTokens + tokens
}
fun createTestnetWithCustomTokens(
testnetTokensConfig: TestnetTokensConfig,
tokensResponse: UserTokensResponse?,
scanResponse: ScanResponse,
): List<ManagedCryptoCurrency> {
val customTokens = tokensResponse
?.let { createCustomTokens(it, scanResponse) }
?: emptyList()
val testnetTokens = testnetTokensConfig.tokens.map { testnetToken ->
ManagedCryptoCurrency.Token(
id = ManagedCryptoCurrency.ID(testnetToken.id),
name = testnetToken.name,
symbol = testnetToken.symbol,
iconUrl = getIconUrl(testnetToken.id),
availableNetworks = testnetToken.networks?.mapNotNull { network ->
createSource(
networkId = network.id,
contractAddress = network.address,
decimals = network.decimalCount,
scanResponse = scanResponse,
)
} ?: emptyList(),
addedIn = findAddedInNetworks(testnetToken.id, tokensResponse, scanResponse),
)
}
return customTokens + testnetTokens
}
private fun createCustomTokens(
tokensResponse: UserTokensResponse,
scanResponse: ScanResponse,
): List<ManagedCryptoCurrency> = tokensResponse.tokens
.mapNotNull { token ->
maybeCreateCustomToken(token, scanResponse)
}
private fun maybeCreateCustomToken(
token: UserTokensResponse.Token,
scanResponse: ScanResponse,
): ManagedCryptoCurrency? {
val blockchain = Blockchain.fromNetworkId(token.networkId)
?.takeUnless { it in excludedBlockchains }
?: return null
if (!checkIsCustomToken(token, blockchain, scanResponse.derivationStyleProvider)) {
return null
}
val network = getNetwork(
blockchain = blockchain,
extraDerivationPath = token.derivationPath,
scanResponse = scanResponse,
excludedBlockchains = excludedBlockchains,
) ?: return null
val contractAddress = token.contractAddress
return if (contractAddress.isNullOrBlank()) {
ManagedCryptoCurrency.Custom.Coin(
currencyId = getCoinId(network, blockchain.toCoinId()),
name = token.name,
symbol = token.symbol,
iconUrl = getIconUrl(blockchain.id),
network = network,
)
} else {
ManagedCryptoCurrency.Custom.Token(
currencyId = getTokenId(
network = network,
rawTokenId = token.id?.let { CryptoCurrency.RawID(it) },
contractAddress = contractAddress,
),
name = token.name,
symbol = token.symbol,
iconUrl = token.id?.let { getIconUrl(it) },
contractAddress = contractAddress,
network = network,
decimals = token.decimals,
)
}
}
private fun createToken(
coinResponse: CoinsResponse.Coin,
tokensResponse: UserTokensResponse?,
imageHost: String?,
scanResponse: ScanResponse?,
): ManagedCryptoCurrency? {
if (coinResponse.networks.isEmpty() || !coinResponse.active) return null
val availableNetworks = coinResponse.networks
.applyL2Compatibility(coinResponse.id)
.mapNotNull { network ->
createSource(
networkId = network.networkId,
contractAddress = network.contractAddress,
decimals = network.decimalCount?.toInt(),
scanResponse = scanResponse,
)
}
.ifEmpty { return null }
return ManagedCryptoCurrency.Token(
id = ManagedCryptoCurrency.ID(coinResponse.id),
name = coinResponse.name,
symbol = coinResponse.symbol,
iconUrl = getIconUrl(coinResponse.id, imageHost),
availableNetworks = availableNetworks,
addedIn = findAddedInNetworks(coinResponse.id, tokensResponse, scanResponse),
)
}
private fun createSource(
networkId: String,
contractAddress: String?,
decimals: Int?,
scanResponse: ScanResponse?,
extraDerivationPath: String? = null,
): SourceNetwork? {
val blockchain = Blockchain.fromNetworkId(networkId)
?.takeUnless { it in excludedBlockchains }
?: return null
val network = getNetwork(
blockchain,
extraDerivationPath,
scanResponse?.derivationStyleProvider,
excludedBlockchains,
canHandleTokens = scanResponse?.let {
it.card.canHandleToken(blockchain, it.cardTypesResolver, excludedBlockchains)
} ?: true,
) ?: return null
return when {
contractAddress.isNullOrBlank() -> SourceNetwork.Main(
network = network,
decimals = blockchain.decimals(),
isL2Network = l2BlockchainsList.contains(blockchain),
)
network.canHandleTokens -> SourceNetwork.Default(
network = network,
decimals = decimals ?: return null,
contractAddress = contractAddress,
)
else -> null
}
}
private fun findAddedInNetworks(
currencyId: String,
tokensResponse: UserTokensResponse?,
scanResponse: ScanResponse?,
): Set<Network> {
if (tokensResponse == null) return emptySet()
return tokensResponse.tokens
.filter { getL2CompatibilityTokenComparison(it, currencyId) }
.mapNotNullTo(mutableSetOf()) { token ->
val blockchain = Blockchain.fromNetworkId(token.networkId)
if (blockchain != null && blockchain !in excludedBlockchains) {
getNetwork(
blockchain,
token.derivationPath,
scanResponse?.derivationStyleProvider,
excludedBlockchains,
canHandleTokens = scanResponse?.let {
it.card.canHandleToken(blockchain, it.cardTypesResolver, excludedBlockchains)
} ?: true,
)
} else {
null
}
}
}
private fun getIconUrl(id: String, imageHost: String? = DEFAULT_IMAGE_HOST): String {
return "${imageHost ?: DEFAULT_IMAGE_HOST}large/$id.png"
}
private fun checkIsCustomToken(
token: UserTokensResponse.Token,
blockchain: Blockchain,
derivationStyleProvider: DerivationStyleProvider,
): Boolean = token.id.isNullOrBlank() ||
checkIsCustomDerivationPath(token.derivationPath, blockchain, derivationStyleProvider)
private fun checkIsCustomDerivationPath(
derivationPath: String?,
blockchain: Blockchain,
derivationStyleProvider: DerivationStyleProvider,
): Boolean = derivationPath != blockchain.derivationPath(derivationStyleProvider.getDerivationStyle())?.rawPath
private companion object {
const val DEFAULT_IMAGE_HOST = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/"
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.managetokens.utils
import com.tangem.blockchain.blockchains.cardano.CardanoTokenAddressConverter
import com.tangem.blockchain.blockchains.hedera.HederaTokenAddressConverter
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.common.currency.getBlockchain
import com.tangem.domain.tokens.model.Network
internal class TokenAddressesConverter {
private val hederaTokenAddressConverter = HederaTokenAddressConverter()
private val cardanoTokenAddressConverter = CardanoTokenAddressConverter()
fun convertTokenAddress(networkId: Network.ID, contractAddress: String, symbol: String?): String {
val convertedAddress = when (getBlockchain(networkId)) {
Blockchain.Hedera,
Blockchain.HederaTestnet,
-> hederaTokenAddressConverter.convertToTokenId(contractAddress)
Blockchain.Cardano -> {
// TODO: [REDACTED_JIRA]
cardanoTokenAddressConverter.convertToFingerprint(contractAddress, symbol)
}
else -> contractAddress
}
return requireNotNull(convertedAddress) {
"Token contract address is invalid"
}
}
}

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

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,48 @@
import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants
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.markets"
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.core.pagination)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.configToggles)
implementation(projects.domain.legacy)
implementation(projects.domain.markets)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.tokens)
implementation(projects.data.common)
implementation(projects.libs.blockchainSdk)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Others dependencies
implementation(deps.kotlin.coroutines)
implementation(deps.jodatime)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.timber)
implementation(tangemDeps.blockchain)
kaptForObfuscatingVariants(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
}

View file

@ -0,0 +1,351 @@
package com.tangem.data.markets
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.compatibility.applyL2Compatibility
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.currency.getNetwork
import com.tangem.data.common.utils.retryOnError
import com.tangem.data.markets.analytics.MarketsDataAnalyticsEvent
import com.tangem.data.markets.converters.*
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.markets.TangemTechMarketsApi
import com.tangem.datasource.api.markets.models.response.TokenMarketExchangesResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.TangemTechApi.Companion.marketsQuoteFields
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.markets.*
import com.tangem.domain.markets.repositories.MarketsTokenRepository
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.pagination.*
import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
@Suppress("LongParameterList")
internal class DefaultMarketsTokenRepository(
private val marketsApi: TangemTechMarketsApi,
private val tangemTechApi: TangemTechApi,
private val userWalletsStore: UserWalletsStore,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val excludedBlockchains: ExcludedBlockchains,
private val cacheRegistry: CacheRegistry,
private val tokenExchangesStore: RuntimeStateStore<List<TokenMarketExchangesResponse.Exchange>>,
) : MarketsTokenRepository {
private val tokenMarketInfoConverter: TokenMarketInfoConverter = TokenMarketInfoConverter(excludedBlockchains)
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
private fun createTokenMarketsFetcher(firstBatchSize: Int, nextBatchSize: Int) = LimitOffsetBatchFetcher(
prefetchDistance = firstBatchSize,
batchSize = nextBatchSize,
subFetcher = object : LimitOffsetBatchFetcher.SubFetcher<TokenMarketListConfig, List<TokenMarket>> {
val requestTimeStamp = AtomicLong(0)
override suspend fun fetch(
request: LimitOffsetBatchFetcher.Request<TokenMarketListConfig>,
lastResult: BatchFetchResult<List<TokenMarket>>?,
isFirstBatchFetching: Boolean,
): BatchFetchResult<List<TokenMarket>> {
val searchText =
if (request.params.searchText.isNullOrBlank()) null else request.params.searchText
val requestCall = suspend {
marketsApi.getCoinsList(
currency = request.params.fiatPriceCurrency,
interval = request.params.priceChangeInterval.toRequestParam(),
order = request.params.order.toRequestParam(),
search = searchText,
offset = request.offset,
limit = request.limit,
timestamp = if (isFirstBatchFetching) null else requestTimeStamp.get(),
).getOrThrow()
}
// we shouldn't infinitely retry on the first batch request
val res = if (isFirstBatchFetching) {
catchListErrorAndSendEvent { requestCall() }
} else {
retryOnError(priority = true) {
catchListErrorAndSendEvent { requestCall() }
}
}
if (isFirstBatchFetching) {
requestTimeStamp.set(res.timestamp ?: 0)
}
val last = res.tokens.size < request.limit
return BatchFetchResult.Success(
data = TokenMarketListConverter.convert(res),
last = last,
empty = res.tokens.isEmpty(),
)
}
},
)
override fun getTokenListFlow(
batchingContext: BatchingContext<Int, TokenMarketListConfig, TokenMarketUpdateRequest>,
firstBatchSize: Int,
nextBatchSize: Int,
): BatchFlow<Int, List<TokenMarket>, TokenMarketUpdateRequest> {
val tokenMarketsUpdateFetcher = MarketsBatchUpdateFetcher(
tangemTechApi = tangemTechApi,
marketsApi = marketsApi,
analyticsEventHandler = analyticsEventHandler,
onApiError = {
analyticsEventHandler.send(createListErrorEvent(it).toEvent())
},
)
val atomicInteger = AtomicInteger(0)
return BatchListSource(
fetchDispatcher = dispatcherProvider.io,
context = batchingContext,
generateNewKey = { atomicInteger.getAndIncrement() },
batchFetcher = createTokenMarketsFetcher(firstBatchSize = firstBatchSize, nextBatchSize = nextBatchSize),
updateFetcher = tokenMarketsUpdateFetcher,
).toBatchFlow()
}
override suspend fun getChart(
fiatCurrencyCode: String,
interval: PriceChangeInterval,
tokenId: CryptoCurrency.RawID,
tokenSymbol: String,
) = withContext(dispatcherProvider.io) {
val mappedTokenId = getTokenIdIfL2Network(tokenId.value)
val response = marketsApi.getCoinChart(
currency = fiatCurrencyCode,
coinId = mappedTokenId,
interval = interval.toRequestParam(),
)
val result = catchDetailsErrorAndSendEvent(
request = MarketsDataAnalyticsEvent.Details.Error.Request.Chart,
tokenSymbol = tokenSymbol,
) {
response.getOrThrow()
}
return@withContext TokenChartConverter.convert(
interval = interval,
value = result,
// === Analytics ===
onNullPresented = {
analyticsEventHandler.send(
MarketsDataAnalyticsEvent.ChartNullValuesError(
requestPath = "coins/history",
errorType = MarketsDataAnalyticsEvent.Type.Custom,
),
)
},
)
}
override suspend fun getChartPreview(
fiatCurrencyCode: String,
interval: PriceChangeInterval,
tokenId: CryptoCurrency.RawID,
tokenSymbol: String,
) = withContext(dispatcherProvider.io) {
val mappedTokenId = getTokenIdIfL2Network(tokenId.value)
val chart = catchListErrorAndSendEvent {
marketsApi.getCoinsListCharts(
coinIds = mappedTokenId,
currency = fiatCurrencyCode,
interval = interval.toRequestParam(),
).getOrThrow()[mappedTokenId] ?: error(
"No chart preview data for the token $mappedTokenId",
)
}
return@withContext TokenChartConverter.convert(
interval = interval,
value = chart,
// === Analytics ===
onNullPresented = {
analyticsEventHandler.send(
MarketsDataAnalyticsEvent.ChartNullValuesError(
requestPath = "coins/history_preview",
errorType = MarketsDataAnalyticsEvent.Type.Custom,
),
)
},
)
}
override suspend fun getTokenInfo(
fiatCurrencyCode: String,
tokenId: CryptoCurrency.RawID,
tokenSymbol: String,
languageCode: String,
) = withContext(dispatcherProvider.io) {
val result = catchDetailsErrorAndSendEvent(
request = MarketsDataAnalyticsEvent.Details.Error.Request.Info,
tokenSymbol = tokenSymbol,
) {
marketsApi.getCoinMarketData(
currency = fiatCurrencyCode,
coinId = tokenId.value,
language = languageCode,
).getOrThrow()
}
val resultResponse = result.applyL2Compatibility(tokenId.value)
return@withContext tokenMarketInfoConverter.convert(resultResponse)
}
override suspend fun getTokenQuotes(fiatCurrencyCode: String, tokenId: CryptoCurrency.RawID, tokenSymbol: String) =
withContext(dispatcherProvider.io) {
// for second markets iteration we should use extended api method with all required fields
val result = catchDetailsErrorAndSendEvent(
request = MarketsDataAnalyticsEvent.Details.Error.Request.Info,
tokenSymbol = tokenSymbol,
) {
tangemTechApi.getQuotes(
currencyId = fiatCurrencyCode,
coinIds = tokenId.value,
fields = marketsQuoteFields.joinToString(separator = ","),
).getOrThrow()
}
return@withContext TokenQuotesShortConverter.convert(tokenId, result).toFull()
}
override suspend fun createCryptoCurrency(
userWalletId: UserWalletId,
token: TokenMarketParams,
network: TokenMarketInfo.Network,
): CryptoCurrency? {
val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("UserWalletId [$userWalletId] not found")
val blockchain = Blockchain.fromNetworkId(network.networkId) ?: error("Unknown network [${network.networkId}]")
return if (network.contractAddress == null) {
cryptoCurrencyFactory.createCoin(
blockchain = blockchain,
extraDerivationPath = null,
scanResponse = userWallet.scanResponse,
)
} else {
val currencyNetwork = getNetwork(
blockchain = blockchain,
extraDerivationPath = null,
scanResponse = userWallet.scanResponse,
excludedBlockchains = excludedBlockchains,
) ?: return null
cryptoCurrencyFactory.createToken(
network = currencyNetwork,
rawId = token.id,
name = token.name,
symbol = token.symbol,
decimals = network.decimalCount ?: error("Unknown decimal"),
contractAddress = network.contractAddress!!,
)
}
}
override suspend fun getTokenExchanges(tokenId: CryptoCurrency.RawID): List<TokenMarketExchange> {
return withContext(dispatcherProvider.io) {
cacheRegistry.invokeOnExpire(key = "coins/$tokenId/exchanges", skipCache = false) {
val response = marketsApi.getCoinExchanges(coinId = tokenId.value).getOrThrow()
tokenExchangesStore.store(value = response.exchanges)
}
TokenMarketExchangeConverter.convertList(input = tokenExchangesStore.get().value)
}
}
inline fun <T> catchListErrorAndSendEvent(block: () -> T): T {
return catchErrorAndSendEvent(block, ::createListErrorEvent)
}
private inline fun <T> catchDetailsErrorAndSendEvent(
request: MarketsDataAnalyticsEvent.Details.Error.Request,
tokenSymbol: String,
block: () -> T,
): T {
return catchErrorAndSendEvent(block) { error ->
createDetailsErrorEvent(error, request, tokenSymbol)
}
}
private inline fun <T> catchErrorAndSendEvent(
block: () -> T,
createErrorEvent: (ApiResponseError) -> MarketsDataAnalyticsEvent,
): T {
return try {
block()
} catch (e: ApiResponseError) {
val errorEvent = createErrorEvent(e)
analyticsEventHandler.send(errorEvent.toEvent())
throw e
}
}
private fun createListErrorEvent(error: ApiResponseError): MarketsDataAnalyticsEvent.List.Error {
return createErrorEvent(error) { errorType, errorCode, errorMessage ->
MarketsDataAnalyticsEvent.List.Error(
errorType = errorType,
errorCode = errorCode,
errorMessage = errorMessage,
)
}
}
private fun createDetailsErrorEvent(
error: ApiResponseError,
request: MarketsDataAnalyticsEvent.Details.Error.Request,
tokenSymbol: String,
): MarketsDataAnalyticsEvent.Details.Error {
return createErrorEvent(error) { errorType, errorCode, errorMessage ->
MarketsDataAnalyticsEvent.Details.Error(
errorType = errorType,
errorCode = errorCode,
errorMessage = errorMessage,
request = request,
tokenSymbol = tokenSymbol,
)
}
}
private inline fun <T> createErrorEvent(
error: ApiResponseError,
createEvent: (MarketsDataAnalyticsEvent.Type, Int?, String) -> T,
): T {
return when (error) {
is ApiResponseError.HttpException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Http, error.code.code, error.message.orEmpty())
}
is ApiResponseError.TimeoutException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Timeout, null, error.message.orEmpty())
}
is ApiResponseError.NetworkException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Network, null, error.message.orEmpty())
}
is ApiResponseError.UnknownException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Unknown, null, error.message.orEmpty())
}
}
}
}

View file

@ -0,0 +1,145 @@
package com.tangem.data.markets
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.common.utils.retryOnError
import com.tangem.data.markets.analytics.MarketsDataAnalyticsEvent
import com.tangem.data.markets.converters.TokenMarketChartsConverter
import com.tangem.data.markets.converters.TokenQuotesShortConverter
import com.tangem.data.markets.converters.toRequestParam
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.markets.TangemTechMarketsApi
import com.tangem.datasource.api.markets.models.response.TokenMarketChartListResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.TangemTechApi.Companion.marketsQuoteFields
import com.tangem.domain.markets.TokenMarket
import com.tangem.domain.markets.TokenMarketUpdateRequest
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchUpdateFetcher
import com.tangem.pagination.BatchUpdateResult
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
internal class MarketsBatchUpdateFetcher(
private val marketsApi: TangemTechMarketsApi,
private val tangemTechApi: TangemTechApi,
private val analyticsEventHandler: AnalyticsEventHandler,
private val onApiError: (ApiResponseError) -> Unit,
) : BatchUpdateFetcher<Int, List<TokenMarket>, TokenMarketUpdateRequest> {
override suspend fun BatchUpdateFetcher.UpdateContext<Int, List<TokenMarket>>.fetchUpdateAsync(
toUpdate: List<Batch<Int, List<TokenMarket>>>,
updateRequest: TokenMarketUpdateRequest,
) {
val idsToUpdate = toUpdate.map { batch ->
batch.key to batch.data.map { it.id }
}
when (updateRequest) {
is TokenMarketUpdateRequest.UpdateChart -> coroutineScope {
val updateTasks = idsToUpdate.map { batchIds ->
async {
retryOnError {
catchApiError(onApiError) {
marketsApi.getCoinsListCharts(
coinIds = batchIds.second.joinToString(separator = ","),
interval = updateRequest.interval.toRequestParam(),
currency = updateRequest.currency,
).getOrThrow()
}
}
}
}
updateTasks.forEachIndexed { index, deferred ->
launch {
val res = deferred.await()
checkForNulls(res)
val batchToUpdate = toUpdate[index]
update {
val resBatch = changeChartsInBatches(
updateRequest = updateRequest,
batchToUpdate = batchToUpdate,
update = res,
)
BatchUpdateResult.Success(resBatch)
}
}
}
}
is TokenMarketUpdateRequest.UpdateQuotes -> {
val quotesRes = retryOnError {
catchApiError(onApiError) {
tangemTechApi.getQuotes(
currencyId = updateRequest.currencyId,
coinIds = idsToUpdate.map { it.second }.flatten().joinToString(separator = ","),
fields = marketsQuoteFields.joinToString(separator = ","),
).getOrThrow()
}
}
update {
val res = toUpdate.map { batch ->
batch.copy(
data = batch.data.map {
it.copy(tokenQuotesShort = TokenQuotesShortConverter.convert(it.id, quotesRes))
},
)
}
BatchUpdateResult.Success(res)
}
}
}
}
private fun List<Batch<Int, List<TokenMarket>>>.changeChartsInBatches(
updateRequest: TokenMarketUpdateRequest.UpdateChart,
batchToUpdate: Batch<Int, List<TokenMarket>>,
update: TokenMarketChartListResponse,
): List<Batch<Int, List<TokenMarket>>> = mapNotNull { resultBatch ->
if (batchToUpdate.key != resultBatch.key) return@mapNotNull null
Batch(
key = batchToUpdate.key,
data = batchToUpdate.data.map {
it.copy(
tokenCharts = TokenMarketChartsConverter.convert(
chartsToCopy = it.tokenCharts,
tokenId = it.id,
interval = updateRequest.interval,
value = update,
),
)
},
)
}
private fun checkForNulls(response: TokenMarketChartListResponse) {
response.values.forEach { chart ->
chart.prices.forEach { (_, price) ->
if (price == null) {
analyticsEventHandler.send(
MarketsDataAnalyticsEvent.ChartNullValuesError(
requestPath = "coins/history_preview",
errorType = MarketsDataAnalyticsEvent.Type.Custom,
),
)
return
}
}
}
}
private inline fun <T> catchApiError(onError: (ApiResponseError) -> Unit, block: () -> T): T {
return try {
block()
} catch (e: ApiResponseError) {
onError(e)
throw e
}
}
}

View file

@ -0,0 +1,89 @@
package com.tangem.data.markets.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
sealed interface MarketsDataAnalyticsEvent {
sealed class List(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Markets", event = event, params = params), MarketsDataAnalyticsEvent {
data class Error(
val errorType: Type,
val errorCode: Int? = null,
val errorMessage: String,
) : List(
event = "Data Error",
params = buildMap {
put(AnalyticsParam.ERROR_TYPE, errorType.value)
put(AnalyticsParam.ERROR_CODE, errorCode?.toString() ?: IS_NOT_HTTP_ERROR)
put(AnalyticsParam.ERROR_MESSAGE, errorMessage)
},
)
}
sealed class Details(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params), MarketsDataAnalyticsEvent {
data class Error(
val request: Request,
val tokenSymbol: String,
val errorType: Type,
val errorCode: Int? = null,
val errorMessage: String,
) : Details(
event = "Data Error",
params = buildMap {
put("Source", request.source)
put("Token", tokenSymbol)
put(AnalyticsParam.ERROR_TYPE, errorType.value)
put(AnalyticsParam.ERROR_CODE, errorCode?.toString() ?: IS_NOT_HTTP_ERROR)
put(AnalyticsParam.ERROR_MESSAGE, errorMessage)
},
) {
enum class Request(val source: String) {
Chart("Chart"),
Info("Blocks"),
}
}
}
fun toEvent(): AnalyticsEvent = when (this) {
is ChartNullValuesError -> this
is List -> this
is Details -> this
}
data class ChartNullValuesError(
val requestPath: String,
val errorType: Type,
val errorCode: Int? = null,
) : AnalyticsEvent(
category = "Markets / Chart",
event = "Data Error",
params = buildMap {
put("Request path", requestPath)
put(AnalyticsParam.ERROR_TYPE, errorType.value)
put(AnalyticsParam.ERROR_CODE, errorCode?.toString() ?: IS_NOT_HTTP_ERROR)
put(AnalyticsParam.ERROR_MESSAGE, "Chart data contains null values from the API")
},
),
MarketsDataAnalyticsEvent
enum class Type(val value: String) {
Http("Http"),
Timeout("Timeout"),
Network("Network"),
Custom("Custom"),
Unknown("Unknown"),
}
private companion object {
const val IS_NOT_HTTP_ERROR = "Is not http error"
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.data.markets.converters
import com.tangem.datasource.api.markets.models.response.TokenMarketChartResponse
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenChart
internal object TokenChartConverter {
fun convert(
interval: PriceChangeInterval,
value: TokenMarketChartResponse,
onNullPresented: () -> Unit = {},
): TokenChart {
val points = value.prices.mapNotNull { p -> p.value?.let { p.key to it } }.toMap()
if (points.size < points.values.size) {
onNullPresented()
}
return TokenChart(
interval = interval,
priceY = points.values.toList(),
timeStamps = points.keys.toList(),
)
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.data.markets.converters
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketListConfig
internal fun TokenMarketListConfig.Interval.toRequestParam(): String = when (this) {
TokenMarketListConfig.Interval.H24 -> "24h"
TokenMarketListConfig.Interval.WEEK -> "1w"
TokenMarketListConfig.Interval.MONTH -> "30d"
}
internal fun TokenMarketListConfig.Order.toRequestParam(): String = when (this) {
TokenMarketListConfig.Order.ByRating -> "rating"
TokenMarketListConfig.Order.Trending -> "trending"
TokenMarketListConfig.Order.Buyers -> "buyers"
TokenMarketListConfig.Order.TopGainers -> "gainers"
TokenMarketListConfig.Order.TopLosers -> "losers"
}
internal fun PriceChangeInterval.toRequestParam(): String = when (this) {
PriceChangeInterval.H24 -> "24h"
PriceChangeInterval.WEEK -> "1w"
PriceChangeInterval.MONTH -> "1m"
PriceChangeInterval.MONTH3 -> "3m"
PriceChangeInterval.MONTH6 -> "6m"
PriceChangeInterval.YEAR -> "1y"
PriceChangeInterval.ALL_TIME -> "all"
}

View file

@ -0,0 +1,39 @@
package com.tangem.data.markets.converters
import com.tangem.datasource.api.markets.models.response.TokenMarketChartListResponse
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarket
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.tokens.model.CryptoCurrency
internal object TokenMarketChartsConverter {
fun convert(
chartsToCopy: TokenMarket.Charts,
tokenId: CryptoCurrency.RawID,
interval: TokenMarketListConfig.Interval,
value: TokenMarketChartListResponse,
): TokenMarket.Charts {
val prices = requireNotNull(value[tokenId.value]) {
"$tokenId is not found in the response. This shouldn't have happened."
}
return when (interval) {
TokenMarketListConfig.Interval.H24 -> chartsToCopy.copy(
h24 = TokenChartConverter.convert(interval.toPriceChangeInterval(), prices),
)
TokenMarketListConfig.Interval.WEEK -> chartsToCopy.copy(
week = TokenChartConverter.convert(interval.toPriceChangeInterval(), prices),
)
TokenMarketListConfig.Interval.MONTH -> chartsToCopy.copy(
month = TokenChartConverter.convert(interval.toPriceChangeInterval(), prices),
)
else -> error("unsupported interval=$interval. This shouldn't have happened.")
}
}
private fun TokenMarketListConfig.Interval.toPriceChangeInterval(): PriceChangeInterval = when (this) {
TokenMarketListConfig.Interval.H24 -> PriceChangeInterval.H24
TokenMarketListConfig.Interval.WEEK -> PriceChangeInterval.WEEK
TokenMarketListConfig.Interval.MONTH -> PriceChangeInterval.MONTH
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.data.markets.converters
import com.tangem.datasource.api.markets.models.response.TokenMarketExchangesResponse
import com.tangem.domain.markets.BuildConfig
import com.tangem.domain.markets.TokenMarketExchange
import com.tangem.utils.converter.Converter
/**
* Converter from [TokenMarketExchangesResponse.Exchange] to [TokenMarketExchange]
*
[REDACTED_AUTHOR]
*/
internal object TokenMarketExchangeConverter : Converter<TokenMarketExchangesResponse.Exchange, TokenMarketExchange> {
private const val PROD_IMAGE_HOST = "https://s3.eu-central-1.amazonaws.com/tangem.api/exchanges/"
private const val DEV_IMAGE_HOST = "https://s3.eu-central-1.amazonaws.com/tangem.api.dev/exchanges/"
private val RISKY_INTERVAL = 0..3
private val CAUTION_INTERVAL = 4..7
override fun convert(value: TokenMarketExchangesResponse.Exchange): TokenMarketExchange {
return with(value) {
TokenMarketExchange(
id = id,
name = name,
imageUrl = "${getImageHost()}large/$id.png",
isCentralized = isCentralized,
volumeInUsd = volumeInUsd,
trustScore = when (trustScore) {
null,
in RISKY_INTERVAL,
-> TokenMarketExchange.TrustScore.Risky
in CAUTION_INTERVAL -> TokenMarketExchange.TrustScore.Caution
else -> TokenMarketExchange.TrustScore.Trusted
},
)
}
}
private fun getImageHost(): String {
return if (BuildConfig.TESTER_MENU_ENABLED) {
DEV_IMAGE_HOST
} else {
PROD_IMAGE_HOST
}
}
}

View file

@ -0,0 +1,202 @@
package com.tangem.data.markets.converters
import android.net.Uri
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.datasource.api.markets.models.response.TokenMarketInfoResponse
import com.tangem.domain.markets.BuildConfig
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenQuotes
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class TokenMarketInfoConverter(
private val excludedBlockchains: ExcludedBlockchains,
) : Converter<TokenMarketInfoResponse, TokenMarketInfo> {
override fun convert(value: TokenMarketInfoResponse): TokenMarketInfo {
return with(value) {
TokenMarketInfo(
id = id,
name = name,
symbol = symbol,
quotes = getQuotes(),
networks = networks?.convert(),
shortDescription = shortDescription,
fullDescription = fullDescription,
insights = insights?.convert(),
metrics = metrics?.convert(),
securityData = securityData?.convert(),
links = links?.convert(),
pricePerformance = pricePerformance?.convert(),
exchangesAmount = exchangesAmount,
)
}
}
private fun TokenMarketInfoResponse.getQuotes(): TokenQuotes {
return TokenQuotes(
currentPrice = currentPrice,
h24ChangePercent = priceChangePercentage?.getPercentageByInterval { day },
weekChangePercent = priceChangePercentage?.getPercentageByInterval { week },
monthChangePercent = priceChangePercentage?.getPercentageByInterval { month },
m3ChangePercent = priceChangePercentage?.getPercentageByInterval { threeMonths },
m6ChangePercent = priceChangePercentage?.getPercentageByInterval { sixMonths },
yearChangePercent = priceChangePercentage?.getPercentageByInterval { year },
allTimeChangePercent = priceChangePercentage?.getPercentageByInterval { allTime },
)
}
private fun TokenMarketInfoResponse.PriceChangePercentage.getPercentageByInterval(
intervalProvider: TokenMarketInfoResponse.PriceChangePercentage.() -> BigDecimal?,
): BigDecimal? {
return (intervalProvider() ?: allTime)?.movePointLeft(2)
}
@JvmName("convertNetwork")
private fun List<TokenMarketInfoResponse.Network>.convert(): List<TokenMarketInfo.Network> {
return mapNotNull { network ->
val blockchain = Blockchain.fromNetworkId(network.networkId)
when {
network.contractAddress.isNullOrEmpty() -> {
TokenMarketInfo.Network(
networkId = network.networkId,
exchangeable = network.exchangeable,
contractAddress = network.contractAddress,
decimalCount = network.decimalCount,
)
}
blockchain != null && blockchain !in excludedBlockchains &&
blockchain.canHandleTokens() -> {
TokenMarketInfo.Network(
networkId = network.networkId,
exchangeable = network.exchangeable,
contractAddress = network.contractAddress,
decimalCount = network.decimalCount,
)
}
else -> null
}
}
}
@JvmName("convertInsight")
private fun TokenMarketInfoResponse.Insights.convert(): TokenMarketInfo.Insights {
return TokenMarketInfo.Insights(
holdersChange = holdersChange?.convert(),
liquidityChange = liquidityChange?.convert(),
buyPressureChange = buyPressureChange?.convert(),
experiencedBuyerChange = experiencedBuyerChange?.convert(),
sourceNetworks = sourceNetworks.orEmpty().map { TokenMarketInfo.Insights.SourceNetwork(it.id, it.name) },
)
}
private fun TokenMarketInfoResponse.Change.convert(): TokenMarketInfo.Change {
return TokenMarketInfo.Change(
day = day,
week = week,
month = month,
)
}
private fun TokenMarketInfoResponse.Metrics.convert(): TokenMarketInfo.Metrics {
return TokenMarketInfo.Metrics(
marketRating = marketRating,
circulatingSupply = circulatingSupply,
marketCap = marketCap,
volume24h = volume24h,
maxSupply = maxSupply,
fullyDilutedValuation = fullyDilutedValuation,
)
}
private fun TokenMarketInfoResponse.SecurityData.convert(): TokenMarketInfo.SecurityData {
return TokenMarketInfo.SecurityData(
totalSecurityScore = totalSecurityScore,
securityScoreProviderData = providerData.map { it.convert() },
)
}
private fun TokenMarketInfoResponse.ProviderData.convert(): TokenMarketInfo.SecurityScoreProvider {
return TokenMarketInfo.SecurityScoreProvider(
providerId = providerId,
providerName = providerName,
urlData = link?.convertToUrlData(),
securityScore = securityScore,
lastAuditDate = lastAuditDate,
iconUrl = "${getImageHost()}large/$providerId.png",
)
}
private fun String.convertToUrlData(): TokenMarketInfo.SecurityScoreProvider.UrlData {
return TokenMarketInfo.SecurityScoreProvider.UrlData(
fullUrl = this,
rootHost = this.extractMainDomainOrNull(),
)
}
private fun TokenMarketInfoResponse.Links.convert(): TokenMarketInfo.Links {
return TokenMarketInfo.Links(
officialLinks = officialLinks?.convert(),
social = social?.convert(),
repository = repository?.convert(),
blockchainSite = blockchainSite?.convert(),
)
}
@JvmName("convertLink")
private fun List<TokenMarketInfoResponse.Link>.convert(): List<TokenMarketInfo.Link> {
return map {
TokenMarketInfo.Link(
title = it.title,
id = it.id,
link = it.link,
)
}
}
private fun TokenMarketInfoResponse.PricePerformance.convert(): TokenMarketInfo.PricePerformance {
return TokenMarketInfo.PricePerformance(
day = day?.convert(),
month = month?.convert(),
allTime = allTime?.convert(),
)
}
private fun TokenMarketInfoResponse.Range.convert(): TokenMarketInfo.Range {
return TokenMarketInfo.Range(
low = low,
high = high,
)
}
private fun String.extractMainDomainOrNull(): String? {
return runCatching {
val uri = Uri.parse(this)
val host = uri.host ?: return null
val parts = host.split(".")
if (parts.size >= 2) {
parts.takeLast(2).joinToString(".")
} else {
host
}
}.getOrNull()
}
private fun getImageHost(): String {
return if (BuildConfig.TESTER_MENU_ENABLED) {
DEV_IMAGE_HOST
} else {
PROD_IMAGE_HOST
}
}
private companion object {
const val PROD_IMAGE_HOST = "https://s3.eu-central-1.amazonaws.com/tangem.api/security_provider/"
const val DEV_IMAGE_HOST = "https://s3.eu-central-1.amazonaws.com/tangem.api.dev/security_provider/"
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.data.markets.converters
import com.tangem.datasource.api.markets.models.response.TokenMarketListResponse
import com.tangem.domain.markets.TokenMarket
import com.tangem.domain.markets.TokenQuotesShort
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.utils.converter.Converter
internal object TokenMarketListConverter : Converter<TokenMarketListResponse, List<TokenMarket>> {
override fun convert(value: TokenMarketListResponse): List<TokenMarket> {
val imageHost = value.imageHost ?: run {
if (value.tokens.isEmpty()) {
return emptyList()
} else {
error("imageHost cannot be null")
}
}
return value.tokens.map { token ->
TokenMarket(
id = CryptoCurrency.RawID(token.id),
name = token.name,
symbol = token.symbol,
marketRating = token.marketRating,
marketCap = token.marketCap,
isUnderMarketCapLimit = token.isUnderMarketCapLimit ?: false,
imageHost = imageHost,
tokenQuotesShort = TokenQuotesShort(
currentPrice = token.currentPrice,
h24ChangePercent = token.priceChangePercentage.h24.movePointLeft(2),
weekChangePercent = token.priceChangePercentage.week1.movePointLeft(2),
monthChangePercent = token.priceChangePercentage.day30.movePointLeft(2),
),
tokenCharts = TokenMarket.Charts(h24 = null, week = null, month = null),
)
}
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.data.markets.converters
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.domain.markets.TokenQuotesShort
import com.tangem.domain.tokens.model.CryptoCurrency
import java.math.BigDecimal
internal object TokenQuotesShortConverter {
fun convert(tokenId: CryptoCurrency.RawID, value: QuotesResponse): TokenQuotesShort {
val quote = requireNotNull(value.quotes[tokenId.value]) {
"$tokenId is not found in the response. This shouldn't have happened."
}
return TokenQuotesShort(
currentPrice = requireNotNull(quote.price) {
"Price is not found in the QuotesResponse. This shouldn't have happened."
},
h24ChangePercent = (quote.priceChange24h ?: BigDecimal.ZERO).movePointLeft(2),
weekChangePercent = (quote.priceChange1w ?: BigDecimal.ZERO).movePointLeft(2),
monthChangePercent = (quote.priceChange30d ?: BigDecimal.ZERO).movePointLeft(2),
)
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.data.markets.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.markets.DefaultMarketsTokenRepository
import com.tangem.datasource.api.markets.TangemTechMarketsApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.markets.repositories.MarketsTokenRepository
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 MarketsDataModule {
@Provides
@Singleton
fun provideMarketsRepository(
marketsApi: TangemTechMarketsApi,
tangemTechApi: TangemTechApi,
userWalletsStore: UserWalletsStore,
dispatchers: CoroutineDispatcherProvider,
analyticsEventHandler: AnalyticsEventHandler,
cacheRegistry: CacheRegistry,
excludedBlockchains: ExcludedBlockchains,
): MarketsTokenRepository {
return DefaultMarketsTokenRepository(
marketsApi = marketsApi,
tangemTechApi = tangemTechApi,
dispatcherProvider = dispatchers,
userWalletsStore = userWalletsStore,
analyticsEventHandler = analyticsEventHandler,
cacheRegistry = cacheRegistry,
tokenExchangesStore = RuntimeStateStore(defaultValue = emptyList()),
excludedBlockchains = excludedBlockchains,
)
}
}

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

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,31 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.onboarding"
}
dependencies {
// region AndroidX libraries
implementation(deps.androidx.datastore)
// endregion
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
// region Core modules
implementation(projects.core.datasource)
// endregion
// region Domain modules
implementation(projects.domain.onboarding)
implementation(projects.domain.models)
// endregion
}

View file

@ -0,0 +1,51 @@
package com.tangem.data.onboarding
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.*
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.onboarding.repository.OnboardingRepository
import kotlinx.coroutines.flow.Flow
/**
* Default implementation of [OnboardingRepository]
*
* @property appPreferencesStore app preferences store
*
[REDACTED_AUTHOR]
*/
internal class DefaultOnboardingRepository(
private val appPreferencesStore: AppPreferencesStore,
) : OnboardingRepository {
override fun wasTwinsOnboardingShown(): Flow<Boolean> {
return appPreferencesStore.get(key = PreferencesKeys.WAS_TWINS_ONBOARDING_SHOWN, default = false)
}
override suspend fun wasTwinsOnboardingShownSync(): Boolean {
return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.WAS_TWINS_ONBOARDING_SHOWN, default = false)
}
override suspend fun saveTwinsOnboardingShown() {
appPreferencesStore.store(key = PreferencesKeys.WAS_TWINS_ONBOARDING_SHOWN, value = true)
}
override suspend fun getUnfinishedFinalizeOnboarding(): ScanResponse? {
return appPreferencesStore
.getObjectSyncOrNull<ScanResponse>(PreferencesKeys.ONBOARDING_FINALIZE_SCAN_RESPONSE_KEY)
}
override suspend fun saveUnfinishedFinalizeOnboarding(scanResponse: ScanResponse) {
appPreferencesStore
.storeObject<ScanResponse>(
PreferencesKeys.ONBOARDING_FINALIZE_SCAN_RESPONSE_KEY,
scanResponse,
)
}
override suspend fun clearUnfinishedFinalizeOnboarding() {
appPreferencesStore.editData { mutablePreferences ->
mutablePreferences.remove(PreferencesKeys.ONBOARDING_FINALIZE_SCAN_RESPONSE_KEY)
}
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.onboarding.di
import com.tangem.data.onboarding.DefaultOnboardingRepository
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.onboarding.repository.OnboardingRepository
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 OnboardingRepositoryModule {
@Provides
@Singleton
fun provideOnboardingRepository(appPreferencesStore: AppPreferencesStore): OnboardingRepository {
return DefaultOnboardingRepository(appPreferencesStore = appPreferencesStore)
}
}

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

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,61 @@
import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants
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.onramp"
}
dependencies {
/** Core modules */
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.core.deepLinks.global)
implementation(projects.core.analytics)
/** Common modules */
implementation(projects.data.common)
/** Domain modules */
implementation(projects.domain.onramp)
implementation(projects.domain.legacy)
implementation(projects.domain.appTheme.models)
implementation(projects.domain.models)
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Others dependencies
implementation(deps.androidx.datastore)
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.immutable.collections)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.timber)
kaptForObfuscatingVariants(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
implementation(deps.kotlin.serialization)
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
// endregion
}

View file

@ -0,0 +1,190 @@
package com.tangem.data.onramp
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.NEW_POLYGON_NAME
import com.tangem.blockchainsdk.utils.OLD_POLYGON_NAME
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
import com.tangem.data.onramp.converters.HotCryptoCurrencyConverter
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.api.tangemTech.models.HotCryptoResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.exchangeservice.hotcrypto.HotCryptoResponseStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.extensions.canHandleBlockchain
import com.tangem.domain.common.extensions.canHandleToken
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.onramp.model.HotCryptoCurrency
import com.tangem.domain.onramp.repositories.HotCryptoRepository
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.runCatching
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
import timber.log.Timber
/**
* Default implementation of [HotCryptoRepository]
*
* @property excludedBlockchains excluded blockchains
* @property hotCryptoResponseStore store of `HotCryptoResponse`
* @property userWalletsStore store of `UserWallet`
* @property tangemTechApi tangem tech api
* @property appPreferencesStore app preferences store
* @property dispatchers dispatchers
* @property analyticsEventHandler analytics event handler
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultHotCryptoRepository(
private val excludedBlockchains: ExcludedBlockchains,
private val hotCryptoResponseStore: HotCryptoResponseStore,
private val userWalletsStore: UserWalletsStore,
private val tangemTechApi: TangemTechApi,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
) : HotCryptoRepository {
private val coroutineScope = CoroutineScope(dispatchers.main + SupervisorJob())
private val hotCryptoJobHolder = JobHolder()
override fun fetchHotCrypto() {
combine(
flow = getWalletsWithTokensFlow(),
flow2 = getHotCryptoFlow(),
) { walletsWithTokens, hotCrypto ->
hotCrypto ?: return@combine emptyMap()
walletsWithTokens
.mapValues { hotCrypto.filterTokens(it) }
.mapKeys { it.key.walletId }
}
.onEach(hotCryptoResponseStore::store)
.launchIn(scope = coroutineScope)
.saveIn(jobHolder = hotCryptoJobHolder)
}
override fun getCurrencies(userWalletId: UserWalletId): Flow<List<HotCryptoCurrency>> {
return hotCryptoResponseStore.get()
.map { it[userWalletId] }
.filterNotNull()
.map {
val userWallet = userWalletsStore.getSyncOrNull(userWalletId)
?: error("UserWalletId [$userWalletId] not found")
HotCryptoCurrencyConverter(
scanResponse = userWallet.scanResponse,
imageHost = it.imageHost,
excludedBlockchains = excludedBlockchains,
)
.convertList(input = it.tokens)
.filterNotNull()
}
}
private fun getWalletsWithTokensFlow(): Flow<Map<UserWallet, List<UserTokensResponse.Token>>> {
return userWalletsStore.userWallets.flatMapLatest { userWallets ->
val flows = userWallets.map { userWallet ->
appPreferencesStore.getObject<UserTokensResponse>(
key = PreferencesKeys.getUserTokensKey(userWallet.walletId.stringValue),
)
.map { userWallet to it?.tokens.orEmpty() }
}
combine(flows) { it.toMap() }
}
}
private fun getHotCryptoFlow(): Flow<HotCryptoResponse?> {
return appPreferencesStore
.getObject<CurrenciesResponse.Currency>(key = PreferencesKeys.SELECTED_APP_CURRENCY_KEY)
.map { it?.id ?: "usd" }
.distinctUntilChanged()
.map { getHotCrypto(appCurrencyId = it).getOrNull() }
.distinctUntilChanged()
}
private suspend fun getHotCrypto(appCurrencyId: String): Result<HotCryptoResponse> {
return runCatching(dispatchers.io) {
tangemTechApi.getHotCrypto(currencyId = appCurrencyId).getOrThrow()
}
.onSuccess { Timber.d("HotCrypto is successfully updated") }
.onFailure {
Timber.e(it, "Unable to fetch hot crypto")
analyticsEventHandler.send(
event = MainScreenAnalyticsEvent.HotTokenError(
errorCode = (it as? ApiResponseError.HttpException)?.code?.code?.toString().orEmpty(),
),
)
}
}
private fun HotCryptoResponse.filterTokens(
walletWithTokens: Map.Entry<UserWallet, List<UserTokensResponse.Token>>,
): HotCryptoResponse {
val (userWallet, tokens) = walletWithTokens
return copy(
tokens = this.tokens
.applyTokensIdMigrations()
.filter { hotToken ->
!tokens.hasAlreadyAdded(hotToken) && userWallet.canHandleHotCrypto(hotToken)
},
)
}
private fun List<UserTokensResponse.Token>.hasAlreadyAdded(hotToken: HotCryptoResponse.Token): Boolean {
return any { token ->
token.id == hotToken.id && token.contractAddress == hotToken.contractAddress &&
token.networkId == hotToken.networkId
}
}
private fun List<HotCryptoResponse.Token>.applyTokensIdMigrations(): List<HotCryptoResponse.Token> {
return this.map {
if (it.id == OLD_POLYGON_NAME) {
it.copy(id = NEW_POLYGON_NAME)
} else {
it
}
}
}
// TODO: [REDACTED_JIRA]
private fun UserWallet.canHandleHotCrypto(hotToken: HotCryptoResponse.Token): Boolean {
val isToken = hotToken.contractAddress != null && hotToken.decimalCount != null
val blockchain = hotToken.networkId?.let { Blockchain.fromNetworkId(it) } ?: return false
return if (isToken) {
scanResponse.card.canHandleToken(
blockchain = blockchain,
cardTypesResolver = cardTypesResolver,
excludedBlockchains = excludedBlockchains,
)
} else {
scanResponse.card.canHandleBlockchain(
blockchain = blockchain,
cardTypesResolver = cardTypesResolver,
excludedBlockchains = excludedBlockchains,
)
}
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.data.onramp
import com.tangem.data.onramp.converters.error.OnrampErrorConverter
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.onramp.model.error.OnrampPairsError
import com.tangem.domain.onramp.model.error.OnrampRedirectError
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
internal class DefaultOnrampErrorResolver(
private val onrampErrorConverter: OnrampErrorConverter,
) : OnrampErrorResolver {
override fun resolve(throwable: Throwable): OnrampError {
return when (throwable) {
is ApiResponseError.HttpException -> {
onrampErrorConverter.convert(throwable.errorBody.orEmpty())
}
is OnrampRedirectError.WrongRequestId -> OnrampError.RedirectError.WrongRequestId
is OnrampRedirectError.VerificationFailed -> OnrampError.RedirectError.VerificationFailed
is OnrampPairsError.PairsNotFound -> OnrampError.PairsNotFound
else -> {
OnrampError.DomainError(throwable.message)
}
}
}
}

View file

@ -0,0 +1,495 @@
package com.tangem.data.onramp
import com.squareup.moshi.Moshi
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
import com.tangem.core.deeplink.global.BuyCurrencyDeepLink
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.onramp.converters.CountryConverter
import com.tangem.data.onramp.converters.CurrencyConverter
import com.tangem.data.onramp.converters.PaymentMethodConverter
import com.tangem.data.onramp.converters.StatusConverter
import com.tangem.data.onramp.converters.error.OnrampErrorConverter
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.TangemExpressValues
import com.tangem.datasource.api.express.models.response.ExchangeProvider
import com.tangem.datasource.api.express.models.response.ExchangeProviderType
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.onramp.models.common.OnrampDestinationDTO
import com.tangem.datasource.api.onramp.models.request.OnrampPairsRequest
import com.tangem.datasource.api.onramp.models.response.OnrampDataJson
import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO
import com.tangem.datasource.api.onramp.models.response.model.PaymentMethodDTO
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.local.onramp.countries.OnrampCountriesStore
import com.tangem.datasource.local.onramp.currencies.OnrampCurrenciesStore
import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore
import com.tangem.datasource.local.onramp.paymentmethods.OnrampPaymentMethodsStore
import com.tangem.datasource.local.onramp.quotes.OnrampQuotesStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.onramp.model.*
import com.tangem.domain.onramp.model.cache.OnrampTransaction
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.onramp.model.error.OnrampPairsError
import com.tangem.domain.onramp.model.error.OnrampRedirectError
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withContext
import org.joda.time.DateTime
import timber.log.Timber
import java.util.UUID
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
internal class DefaultOnrampRepository(
private val onrampApi: OnrampApi,
private val expressApi: TangemExpressApi,
private val dispatchers: CoroutineDispatcherProvider,
private val appPreferencesStore: AppPreferencesStore,
private val paymentMethodsStore: OnrampPaymentMethodsStore,
private val pairsStore: OnrampPairsStore,
private val quotesStore: OnrampQuotesStore,
private val countriesStore: OnrampCountriesStore,
private val currenciesStore: OnrampCurrenciesStore,
private val walletManagersFacade: WalletManagersFacade,
private val dataSignatureVerifier: DataSignatureVerifier,
moshi: Moshi,
) : OnrampRepository {
private val currencyConverter = CurrencyConverter()
private val countryConverter = CountryConverter(currencyConverter)
private val statusConverter = StatusConverter()
private val paymentMethodsConverter = PaymentMethodConverter()
private val onrampDataAdapter = moshi.adapter(OnrampDataJson::class.java)
private val onrampErrorAdapter = moshi.adapter(ExpressErrorResponse::class.java)
private val onrampErrorConverter = OnrampErrorConverter(onrampErrorAdapter)
override fun getCurrencies(): Flow<List<OnrampCurrency>> = currenciesStore.get(CURRENCIES_KEY)
override suspend fun fetchCurrencies() = withContext(dispatchers.io) {
if (!currenciesStore.getSyncOrNull(CURRENCIES_KEY).isNullOrEmpty()) return@withContext
val result = onrampApi.getCurrencies()
.getOrThrow()
.map(currencyConverter::convert)
currenciesStore.store(CURRENCIES_KEY, result)
}
override fun getCountries(): Flow<List<OnrampCountry>> = countriesStore.get(COUNTRIES_KEY)
override suspend fun getCountriesSync(): List<OnrampCountry>? {
return countriesStore.getSyncOrNull(COUNTRIES_KEY)
}
override suspend fun fetchCountries(): List<OnrampCountry> = withContext(dispatchers.io) {
if (!countriesStore.getSyncOrNull(COUNTRIES_KEY).isNullOrEmpty()) return@withContext emptyList()
val result = onrampApi.getCountries()
.getOrThrow()
.map(countryConverter::convert)
countriesStore.store(COUNTRIES_KEY, result)
result
}
override suspend fun getCountryByIp(): OnrampCountry = withContext(dispatchers.io) {
onrampApi.getCountryByIp()
.getOrThrow()
.let(countryConverter::convert)
}
override suspend fun getStatus(txId: String): OnrampStatus = withContext(dispatchers.io) {
onrampApi.getStatus(txId)
.getOrThrow()
.let(statusConverter::convert)
}
override suspend fun saveDefaultCurrency(currency: OnrampCurrency) = withContext(dispatchers.io) {
val country = getDefaultCountrySync() ?: return@withContext
appPreferencesStore.storeObject<OnrampCountryDTO>(
key = PreferencesKeys.ONRAMP_DEFAULT_COUNTRY,
value = countryConverter.convertBack(country.copy(defaultCurrency = currency)),
)
}
override suspend fun getDefaultCurrencySync(): OnrampCurrency? = withContext(dispatchers.io) {
appPreferencesStore
.getObjectSyncOrNull<OnrampCountryDTO>(PreferencesKeys.ONRAMP_DEFAULT_COUNTRY)
?.let(countryConverter::convert)?.defaultCurrency
}
override fun getDefaultCurrency(): Flow<OnrampCurrency?> {
return appPreferencesStore
.getObject<OnrampCountryDTO>(PreferencesKeys.ONRAMP_DEFAULT_COUNTRY)
.map { it?.let(countryConverter::convert)?.defaultCurrency }
}
override suspend fun saveDefaultCountry(country: OnrampCountry) = withContext(dispatchers.io) {
appPreferencesStore.storeObject<OnrampCountryDTO>(
key = PreferencesKeys.ONRAMP_DEFAULT_COUNTRY,
value = countryConverter.convertBack(country),
)
}
override suspend fun getDefaultCountrySync(): OnrampCountry? = withContext(dispatchers.io) {
appPreferencesStore
.getObjectSyncOrNull<OnrampCountryDTO>(PreferencesKeys.ONRAMP_DEFAULT_COUNTRY)
?.let(countryConverter::convert)
}
override fun getDefaultCountry(): Flow<OnrampCountry?> {
return appPreferencesStore
.getObject<OnrampCountryDTO>(PreferencesKeys.ONRAMP_DEFAULT_COUNTRY)
.map { it?.let(countryConverter::convert) }
}
override suspend fun fetchPaymentMethodsIfAbsent() = withContext(dispatchers.io) {
if (paymentMethodsStore.contains(PAYMENT_METHODS_KEY)) return@withContext
val response = safeApiCall(
call = { onrampApi.getPaymentMethods().bind() },
onError = {
Timber.w(it, "Unable to fetch onramp payment methods")
throw it
},
)
paymentMethodsStore.store(PAYMENT_METHODS_KEY, response.removeApplePay())
}
override suspend fun fetchPairs(currency: OnrampCurrency, country: OnrampCountry, cryptoCurrency: CryptoCurrency) =
withContext(dispatchers.io) {
val onrampPairs = async {
safeApiCall(
call = {
onrampApi.getPairs(
body = OnrampPairsRequest(
fromCurrencyCode = currency.code,
countryCode = country.code,
to = listOf(
OnrampDestinationDTO(
contractAddress = cryptoCurrency.getContractAddress(),
network = cryptoCurrency.network.backendId,
),
),
),
).bind()
},
onError = {
Timber.w(it, "Unable to fetch onramp pairs")
throw it
},
)
}
val providers = async {
safeApiCall(
call = { expressApi.getProviders().bind() },
onError = {
Timber.w(it, "Unable to fetch express providers")
throw it
},
)
}
storeOnrampPairs(pairs = onrampPairs.await(), providers = providers.await())
}
override suspend fun fetchQuotes(cryptoCurrency: CryptoCurrency, amount: Amount) = withContext(dispatchers.io) {
val pairs = requireNotNull(pairsStore.getSyncOrNull(PAIRS_KEY)) {
"Unable to get pairs. At this point they must not be null."
}
val amountValue = requireNotNull(amount.value) { "Amount value must not be null" }
val fromAmount = amountValue.movePointRight(amount.decimals).toString()
val currency = requireNotNull(getDefaultCurrencySync()) { "Default currency must not be null" }
val country = requireNotNull(getDefaultCountrySync()) { "Default country must not be null" }
val fromOnrampAmount = OnrampAmount(
value = fromAmount
.toBigDecimalOrDefault()
.movePointLeft(currency.precision),
decimals = currency.precision,
symbol = amount.currencySymbol,
)
val quotes: List<OnrampQuote> = pairs.flatMap { pair ->
pair.providers.flatMap { provider ->
provider.paymentMethods.map { paymentMethod ->
async {
safeApiCall(
call = {
val response = onrampApi.getQuote(
fromCurrencyCode = currency.code,
fromPrecision = currency.precision,
toContractAddress = cryptoCurrency.getContractAddress(),
toNetwork = cryptoCurrency.network.backendId,
paymentMethod = paymentMethod.id,
countryCode = country.code,
fromAmount = fromAmount,
toDecimals = cryptoCurrency.decimals,
providerId = provider.id,
).bind()
OnrampQuote.Data(
fromAmount = fromOnrampAmount,
toAmount = convertToAmount(response.toAmount, cryptoCurrency),
minFromAmount = convertToAmount(response.minFromAmount, cryptoCurrency),
maxFromAmount = convertToAmount(response.maxFromAmount, cryptoCurrency),
paymentMethod = paymentMethod,
provider = provider,
countryCode = response.countryCode,
)
},
onError = { error ->
convertQuoteError(
error = error,
paymentMethod = paymentMethod,
provider = provider,
fromOnrampAmount = fromOnrampAmount,
countryCode = country.code,
)
},
)
}
}
}
}.awaitAll().filterNotNull()
quotesStore.store(QUOTES_KEY, quotes)
}
override fun getQuotes(): Flow<List<OnrampQuote>> {
return quotesStore.get(QUOTES_KEY)
}
override suspend fun getQuotesSync(): List<OnrampQuote>? {
return quotesStore.getSyncOrNull(QUOTES_KEY)
}
override suspend fun getOnrampData(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
quote: OnrampProviderWithQuote.Data,
isDarkTheme: Boolean,
): OnrampTransaction = withContext(dispatchers.io) {
try {
val address = requireNotNull(
value = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network),
lazyMessage = { "Address must not be null" },
)
val fromAmountString = quote.fromAmount.value.movePointRight(quote.fromAmount.decimals).toString()
val currency = requireNotNull(getDefaultCurrencySync()) { "Default currency must not be null" }
val country = requireNotNull(getDefaultCountrySync()) { "Default country must not be null" }
val requestId = UUID.randomUUID().toString()
val data = safeApiCall(
call = {
onrampApi.getData(
fromCurrencyCode = currency.code,
fromPrecision = currency.precision,
toContractAddress = cryptoCurrency.getContractAddress(),
toNetwork = cryptoCurrency.network.backendId,
paymentMethod = quote.paymentMethod.id,
countryCode = country.code,
fromAmount = fromAmountString,
toDecimals = cryptoCurrency.decimals,
providerId = quote.provider.id,
toAddress = address,
redirectUrl = BuyCurrencyDeepLink.ONRAMP_REDIRECT_DEEPLINK,
language = null,
theme = getTheme(isDarkTheme),
requestId = requestId,
).bind()
},
onError = { e ->
Timber.e(e)
throw e
},
)
if (dataSignatureVerifier.verifySignature(data.signature, data.dataJson)) {
val dataJson = requireNotNull(onrampDataAdapter.fromJson(data.dataJson)) {
"Can not parse dataJson. ${data.dataJson}"
}
if (requestId != dataJson.requestId) throw OnrampRedirectError.WrongRequestId
createOnrampTransaction(
txId = data.txId,
quote = quote,
onrampDataJson = dataJson,
userWalletId = userWalletId,
currency = currency,
cryptoCurrency = cryptoCurrency,
residency = country.name,
)
} else {
throw OnrampRedirectError.VerificationFailed
}
} catch (e: Exception) {
Timber.e(e)
throw e
}
}
override suspend fun getAvailablePaymentMethods(): Set<OnrampPaymentMethod> {
val quotes = requireNotNull(quotesStore.getSyncOrNull(QUOTES_KEY)) { "Quotes must not be null" }
return quotes.mapTo(hashSetOf(), OnrampQuote::paymentMethod)
}
override suspend fun saveSelectedPaymentMethod(paymentMethod: OnrampPaymentMethod) {
val dto = paymentMethodsConverter.convertBack(paymentMethod)
paymentMethodsStore.store(SELECTED_PAYMENT_METHOD_KEY, listOf(dto))
}
override fun getSelectedPaymentMethod(): Flow<OnrampPaymentMethod> {
return paymentMethodsStore
.get(SELECTED_PAYMENT_METHOD_KEY)
.mapNotNull { paymentMethods -> paymentMethods.firstOrNull()?.let(paymentMethodsConverter::convert) }
}
override suspend fun clearCache() = withContext(NonCancellable) {
paymentMethodsStore.clear()
pairsStore.clear()
quotesStore.clear()
countriesStore.clear()
currenciesStore.clear()
}
private suspend fun storeOnrampPairs(pairs: List<OnrampPairDTO>, providers: List<ExchangeProvider>) {
if (pairs.isEmpty() || providers.isEmpty()) throw OnrampPairsError.PairsNotFound
val onrampPaymentMethods = getPaymentMethods()
val onrampPairs = pairs.map { pair ->
val onrampProviders = pair.providers.mapNotNull { onrampProviderDTO ->
val providerInfo = providers.find { provider ->
provider.id == onrampProviderDTO.providerId
} ?: return@mapNotNull null
OnrampProvider(
id = onrampProviderDTO.providerId,
info = OnrampProviderInfo(
name = providerInfo.name,
imageLarge = providerInfo.imageLargeUrl,
termsOfUseLink = providerInfo.termsOfUse,
privacyPolicyLink = providerInfo.privacyPolicy,
),
paymentMethods = onrampPaymentMethods.filter { paymentMethod ->
onrampProviderDTO.paymentMethods.any { paymentMethod.id == it }
},
)
}
if (onrampProviders.isEmpty()) throw OnrampPairsError.PairsNotFound
OnrampPair(onrampProviders)
}
pairsStore.store(PAIRS_KEY, onrampPairs)
}
private suspend fun getPaymentMethods(): List<OnrampPaymentMethod> {
return requireNotNull(paymentMethodsStore.getSyncOrNull(PAYMENT_METHODS_KEY)) {
"Onramp payment methods is absent in storage"
}.let(paymentMethodsConverter::convertList)
}
private fun createOnrampTransaction(
txId: String,
quote: OnrampProviderWithQuote.Data,
onrampDataJson: OnrampDataJson,
userWalletId: UserWalletId,
currency: OnrampCurrency,
cryptoCurrency: CryptoCurrency,
residency: String,
): OnrampTransaction {
return OnrampTransaction(
txId = txId,
userWalletId = userWalletId,
fromAmount = quote.fromAmount.value,
fromCurrency = currency,
toAmount = quote.toAmount.value,
toCurrencyId = cryptoCurrency.id.value,
status = OnrampStatus.Status.Created,
externalTxUrl = onrampDataJson.externalTxUrl,
externalTxId = onrampDataJson.externalTxId,
timestamp = DateTime.now().millis,
providerName = quote.provider.info.name,
providerImageUrl = quote.provider.info.imageLarge,
providerType = ExchangeProviderType.ONRAMP.name,
redirectUrl = onrampDataJson.widgetUrl,
paymentMethod = quote.paymentMethod.name,
residency = residency,
)
}
private fun CryptoCurrency.getContractAddress(): String = when (this) {
is CryptoCurrency.Coin -> TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
is CryptoCurrency.Token -> this.contractAddress
}
private fun convertToAmount(value: String, cryptoCurrency: CryptoCurrency): OnrampAmount {
val cryptoValue = requireNotNull(value.toBigDecimalOrNull()) { "Can not parse $value to BigDecimal" }
return OnrampAmount(
value = cryptoValue.movePointLeft(cryptoCurrency.decimals),
symbol = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
)
}
private fun getTheme(isDarkTheme: Boolean): String = if (isDarkTheme) {
PROVIDER_THEME_DARK
} else {
PROVIDER_THEME_LIGHT
}
private fun List<PaymentMethodDTO>.removeApplePay(): List<PaymentMethodDTO> = filterNot { it.id == "apple-pay" }
private fun convertQuoteError(
error: Throwable,
paymentMethod: OnrampPaymentMethod,
provider: OnrampProvider,
fromOnrampAmount: OnrampAmount,
countryCode: String,
) = if (error is ApiResponseError.HttpException) {
val onrampError = onrampErrorConverter.convert(value = error.errorBody.orEmpty())
if (onrampError is OnrampError.AmountError) {
OnrampQuote.AmountError(
paymentMethod = paymentMethod,
provider = provider,
fromAmount = fromOnrampAmount,
error = onrampError,
countryCode = countryCode,
)
} else {
Timber.w(error, "Unable to fetch onramp quotes for ${provider.id}. $error")
OnrampQuote.Error(
paymentMethod = paymentMethod,
provider = provider,
fromAmount = fromOnrampAmount,
error = onrampError,
countryCode = countryCode,
)
}
} else {
Timber.w(error, "Unable to fetch onramp quotes for ${provider.id}. $error")
null
}
private companion object {
const val PAYMENT_METHODS_KEY = "onramp_payment_methods"
const val SELECTED_PAYMENT_METHOD_KEY = "onramp_selected_payment_method"
const val PAIRS_KEY = "onramp_pairs"
const val QUOTES_KEY = "onramp_quotes"
const val COUNTRIES_KEY = "onramp_countries"
const val CURRENCIES_KEY = "onramp_currencies"
const val PROVIDER_THEME_DARK = "dark"
const val PROVIDER_THEME_LIGHT = "light"
}
}

View file

@ -0,0 +1,90 @@
package com.tangem.data.onramp
import com.tangem.data.onramp.converters.TransactionConverter
import com.tangem.data.onramp.models.OnrampTransactionDTO
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSet
import com.tangem.datasource.local.preferences.utils.getObjectSetSync
import com.tangem.domain.onramp.model.OnrampStatus
import com.tangem.domain.onramp.model.cache.OnrampTransaction
import com.tangem.domain.onramp.repositories.OnrampTransactionRepository
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
internal class DefaultOnrampTransactionRepository(
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : OnrampTransactionRepository {
private val transactionConverter = TransactionConverter()
override suspend fun storeTransaction(transaction: OnrampTransaction) {
withContext(dispatchers.io) {
appPreferencesStore.editData { mutablePreferences ->
val stored = mutablePreferences.getObjectSet<OnrampTransactionDTO>(
PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY,
)?.map(transactionConverter::convert) ?: mutableSetOf()
val updated = stored.toMutableSet()
.addOrReplace(transaction) { it.externalTxId == transaction.externalTxId }
.map(transactionConverter::convertBack)
.toSet()
mutablePreferences.setObjectSet<OnrampTransactionDTO>(
key = PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY,
value = updated,
)
}
}
}
override suspend fun getTransactionById(externalTxId: String): OnrampTransaction? = withContext(dispatchers.io) {
val stored = appPreferencesStore.getObjectSetSync<OnrampTransactionDTO>(
PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY,
).map(transactionConverter::convert)
stored.firstOrNull { it.externalTxId == externalTxId }
}
override fun getTransactions(
userWalletId: UserWalletId,
cryptoCurrencyId: CryptoCurrency.ID,
): Flow<List<OnrampTransaction>> = appPreferencesStore
.getObjectSet<OnrampTransactionDTO>(PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY)
.map { transactions ->
transactions.filter {
it.userWalletId == userWalletId && it.toCurrencyId == cryptoCurrencyId.value
}.map(transactionConverter::convert)
}
override suspend fun updateTransactionStatus(externalTxId: String, status: OnrampStatus.Status) =
withContext(dispatchers.io) {
val updatedTx = getTransactionById(externalTxId)?.copy(status = status) ?: return@withContext
storeTransaction(updatedTx)
}
override suspend fun removeTransaction(externalTxId: String) {
withContext(dispatchers.io) {
appPreferencesStore.editData { mutablePreferences ->
runCatching {
val stored = mutablePreferences.getObjectSet<OnrampTransactionDTO>(
PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY,
)?.toMutableSet()
stored?.removeIf { it.externalTxId == externalTxId }
mutablePreferences.setObjectSet<OnrampTransactionDTO>(
key = PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY,
value = stored ?: emptySet(),
)
}
}
}
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.data.onramp.converters
import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.utils.converter.TwoWayConverter
internal class CountryConverter(
private val currencyConverter: CurrencyConverter,
) : TwoWayConverter<OnrampCountryDTO, OnrampCountry> {
override fun convert(value: OnrampCountryDTO) = OnrampCountry(
id = "${value.alpha3}-${value.name}",
name = value.name,
code = value.code,
image = value.image,
alpha3 = value.alpha3,
continent = value.continent,
defaultCurrency = currencyConverter.convert(value.defaultCurrency),
onrampAvailable = value.onrampAvailable,
)
override fun convertBack(value: OnrampCountry): OnrampCountryDTO = OnrampCountryDTO(
name = value.name,
code = value.code,
image = value.image,
alpha3 = value.alpha3,
continent = value.continent,
defaultCurrency = currencyConverter.convertBack(value.defaultCurrency),
onrampAvailable = value.onrampAvailable,
)
}

View file

@ -0,0 +1,24 @@
package com.tangem.data.onramp.converters
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.utils.converter.TwoWayConverter
internal class CurrencyConverter : TwoWayConverter<OnrampCurrencyDTO, OnrampCurrency> {
override fun convert(value: OnrampCurrencyDTO) = OnrampCurrency(
name = value.name,
code = value.code,
image = value.image,
precision = value.precision,
unit = value.unit ?: value.code,
)
override fun convertBack(value: OnrampCurrency): OnrampCurrencyDTO = OnrampCurrencyDTO(
name = value.name,
code = value.code,
image = value.image,
precision = value.precision,
unit = value.unit,
)
}

View file

@ -0,0 +1,115 @@
package com.tangem.data.onramp.converters
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.currency.getNetwork
import com.tangem.datasource.api.tangemTech.models.HotCryptoResponse
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.onramp.model.HotCryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
/**
* Converter from [HotCryptoResponse.Token] to [HotCryptoCurrency]
*
* @property scanResponse scan response
* @property imageHost image host
* @property excludedBlockchains excluded blockchains
*
[REDACTED_AUTHOR]
*/
internal class HotCryptoCurrencyConverter(
private val scanResponse: ScanResponse,
private val imageHost: String?,
private val excludedBlockchains: ExcludedBlockchains,
) : Converter<HotCryptoResponse.Token, HotCryptoCurrency?> {
private val cryptoCurrencyFactory by lazy { CryptoCurrencyFactory(excludedBlockchains) }
override fun convert(value: HotCryptoResponse.Token): HotCryptoCurrency? {
val rawId = value.id?.let(CryptoCurrency::RawID) ?: return null
val network = value.networkId?.let(::createNetwork) ?: return null
val currency = createCryptoCurrencyOrNull(rawId, value, network) ?: return null
return HotCryptoCurrency(
cryptoCurrency = currency.setupIconUrl(id = rawId.value),
quote = createQuote(
fiatRate = value.currentPrice,
priceChange = value.priceChangePercentage,
rawCurrencyId = rawId,
),
)
}
private fun createCryptoCurrencyOrNull(
rawId: CryptoCurrency.RawID,
value: HotCryptoResponse.Token,
network: Network,
): CryptoCurrency? {
val name = value.name
val symbol = value.symbol
if (name.isNullOrBlank() || symbol.isNullOrBlank()) return null
val contractAddress = value.contractAddress
val decimals = value.decimalCount
return if (contractAddress != null && decimals != null) {
cryptoCurrencyFactory.createToken(
network = network,
rawId = rawId,
name = name,
symbol = symbol,
decimals = decimals,
contractAddress = contractAddress,
)
} else {
cryptoCurrencyFactory.createCoin(network = network)
}
}
private fun createNetwork(networkId: String): Network? {
val blockchain = Blockchain.fromNetworkId(networkId) ?: return null
return getNetwork(
blockchain = blockchain,
extraDerivationPath = null,
scanResponse = scanResponse,
excludedBlockchains = excludedBlockchains,
)
}
private fun createQuote(
fiatRate: BigDecimal?,
priceChange: BigDecimal?,
rawCurrencyId: CryptoCurrency.RawID,
): Quote {
return if (fiatRate != null && priceChange != null) {
Quote.Value(
rawCurrencyId = rawCurrencyId,
fiatRate = fiatRate,
priceChange = priceChange.movePointLeft(2),
source = StatusSource.ACTUAL, // It doesn't matter
)
} else {
Quote.Empty(rawCurrencyId)
}
}
private fun CryptoCurrency.setupIconUrl(id: String): CryptoCurrency {
imageHost ?: return this
val iconUrl = "${imageHost}large/$id.png"
return when (this) {
is CryptoCurrency.Coin -> copy(iconUrl = iconUrl)
is CryptoCurrency.Token -> copy(iconUrl = iconUrl)
}
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.onramp.converters
import com.tangem.datasource.api.onramp.models.response.model.PaymentMethodDTO
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.utils.converter.TwoWayConverter
internal class PaymentMethodConverter : TwoWayConverter<PaymentMethodDTO, OnrampPaymentMethod> {
override fun convert(value: PaymentMethodDTO): OnrampPaymentMethod = OnrampPaymentMethod(
id = value.id,
name = value.name,
imageUrl = value.image,
type = PaymentMethodType.getType(value.id),
)
override fun convertBack(value: OnrampPaymentMethod): PaymentMethodDTO = PaymentMethodDTO(
id = value.id,
name = value.name,
image = value.imageUrl,
)
}

View file

@ -0,0 +1,30 @@
package com.tangem.data.onramp.converters
import com.tangem.datasource.api.onramp.models.response.OnrampStatusResponse
import com.tangem.domain.onramp.model.OnrampStatus
import com.tangem.utils.converter.Converter
internal class StatusConverter : Converter<OnrampStatusResponse, OnrampStatus> {
override fun convert(value: OnrampStatusResponse): OnrampStatus {
return OnrampStatus(
txId = value.txId,
providerId = value.providerId,
payoutAddress = value.payoutAddress,
status = OnrampStatus.Status.valueOf(value.status.name),
failReason = value.failReason,
externalTxId = value.externalTxId,
externalTxUrl = value.externalTxUrl,
payoutHash = value.payoutHash,
createdAt = value.createdAt,
fromCurrencyCode = value.fromCurrencyCode,
fromAmount = value.fromAmount,
toContractAddress = value.toContractAddress,
toNetwork = value.toNetwork,
toDecimals = value.toDecimals,
toAmount = value.toAmount,
toActualAmount = value.toActualAmount,
paymentMethod = value.paymentMethod,
countryCode = value.countryCode,
)
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.data.onramp.converters
import com.tangem.data.onramp.models.OnrampTransactionDTO
import com.tangem.datasource.api.onramp.models.response.Status
import com.tangem.domain.onramp.model.OnrampStatus
import com.tangem.domain.onramp.model.cache.OnrampTransaction
import com.tangem.utils.converter.TwoWayConverter
internal class TransactionConverter : TwoWayConverter<OnrampTransactionDTO, OnrampTransaction> {
private val currencyConverter = CurrencyConverter()
override fun convert(value: OnrampTransactionDTO): OnrampTransaction {
return OnrampTransaction(
txId = value.txId,
userWalletId = value.userWalletId,
fromAmount = value.fromAmount,
fromCurrency = currencyConverter.convert(value.fromCurrency),
toAmount = value.toAmount,
toCurrencyId = value.toCurrencyId,
status = OnrampStatus.Status.valueOf(value.status.name),
externalTxUrl = value.externalTxUrl,
externalTxId = value.externalTxId,
timestamp = value.timestamp,
providerName = value.providerName,
providerImageUrl = value.providerImageUrl,
providerType = value.providerType,
redirectUrl = "", // not necessary field
paymentMethod = value.paymentMethod,
residency = value.residency,
)
}
override fun convertBack(value: OnrampTransaction): OnrampTransactionDTO {
return OnrampTransactionDTO(
txId = value.txId,
userWalletId = value.userWalletId,
fromAmount = value.fromAmount,
fromCurrency = currencyConverter.convertBack(value.fromCurrency),
toAmount = value.toAmount,
toCurrencyId = value.toCurrencyId,
status = Status.valueOf(value.status.name),
externalTxUrl = value.externalTxUrl,
externalTxId = value.externalTxId,
timestamp = value.timestamp,
providerName = value.providerName,
providerImageUrl = value.providerImageUrl,
providerType = value.providerType,
paymentMethod = value.paymentMethod,
residency = value.residency,
)
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.data.onramp.converters.error
import com.squareup.moshi.JsonAdapter
import com.tangem.datasource.api.express.models.response.ExpressError
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.utils.converter.Converter
internal class OnrampErrorConverter(
private val jsonAdapter: JsonAdapter<ExpressErrorResponse>,
) : Converter<String, OnrampError> {
@Suppress("MagicNumber")
override fun convert(value: String): OnrampError {
try {
val error = jsonAdapter.fromJson(value)?.error ?: return OnrampError.DomainError(value)
return when (error.code) {
2250 -> tryParseExchangeTooSmallAmountError(error = error)
2251 -> tryParseExchangeTooBigAmountError(error = error)
else -> OnrampError.DataError(error.code.toString(), error.description)
}
} catch (e: Exception) {
return OnrampError.DomainError(e.message)
}
}
private fun tryParseExchangeTooSmallAmountError(error: ExpressError): OnrampError {
val minAmount = error.value?.minAmount ?: return OnrampError.DataError(error.code.toString(), error.description)
val decimals = error.value?.decimals ?: return OnrampError.DataError(error.code.toString(), error.description)
val requiredAmount = minAmount.toBigDecimalOrNull()?.movePointLeft(decimals)
?: return OnrampError.DataError(error.code.toString(), error.description)
return OnrampError.AmountError.TooSmallError(requiredAmount = requiredAmount)
}
private fun tryParseExchangeTooBigAmountError(error: ExpressError): OnrampError {
val maxAmount = error.value?.maxAmount ?: return OnrampError.DataError(error.code.toString(), error.description)
val decimals = error.value?.decimals ?: return OnrampError.DataError(error.code.toString(), error.description)
val requiredAmount = maxAmount.toBigDecimalOrNull()?.movePointLeft(decimals)
?: return OnrampError.DataError(error.code.toString(), error.description)
return OnrampError.AmountError.TooBigError(requiredAmount = requiredAmount)
}
}

View file

@ -0,0 +1,115 @@
package com.tangem.data.onramp.di
import com.squareup.moshi.Moshi
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.onramp.DefaultHotCryptoRepository
import com.tangem.data.onramp.DefaultOnrampErrorResolver
import com.tangem.data.onramp.DefaultOnrampRepository
import com.tangem.data.onramp.DefaultOnrampTransactionRepository
import com.tangem.data.onramp.converters.error.OnrampErrorConverter
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.exchangeservice.hotcrypto.HotCryptoResponseStore
import com.tangem.datasource.local.onramp.countries.OnrampCountriesStore
import com.tangem.datasource.local.onramp.currencies.OnrampCurrenciesStore
import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore
import com.tangem.datasource.local.onramp.paymentmethods.OnrampPaymentMethodsStore
import com.tangem.datasource.local.onramp.quotes.OnrampQuotesStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.onramp.repositories.HotCryptoRepository
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.onramp.repositories.OnrampTransactionRepository
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 OnrampDataModule {
@Provides
@Singleton
fun provideOnrampRepository(
onrampApi: OnrampApi,
expressApi: TangemExpressApi,
dispatchers: CoroutineDispatcherProvider,
appPreferencesStore: AppPreferencesStore,
paymentMethodsStore: OnrampPaymentMethodsStore,
pairsStore: OnrampPairsStore,
quotesStore: OnrampQuotesStore,
countriesStore: OnrampCountriesStore,
currenciesStore: OnrampCurrenciesStore,
walletManagersFacade: WalletManagersFacade,
dataSignatureVerifier: DataSignatureVerifier,
@NetworkMoshi moshi: Moshi,
): OnrampRepository {
return DefaultOnrampRepository(
onrampApi = onrampApi,
expressApi = expressApi,
dispatchers = dispatchers,
appPreferencesStore = appPreferencesStore,
paymentMethodsStore = paymentMethodsStore,
pairsStore = pairsStore,
quotesStore = quotesStore,
currenciesStore = currenciesStore,
countriesStore = countriesStore,
walletManagersFacade = walletManagersFacade,
dataSignatureVerifier = dataSignatureVerifier,
moshi = moshi,
)
}
@Provides
@Singleton
fun provideOnrampTransactionRepository(
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): OnrampTransactionRepository {
return DefaultOnrampTransactionRepository(
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideOnrampErrorResolver(@NetworkMoshi moshi: Moshi): OnrampErrorResolver {
val jsonAdapter = moshi.adapter(ExpressErrorResponse::class.java)
return DefaultOnrampErrorResolver(
OnrampErrorConverter(jsonAdapter),
)
}
@Provides
@Singleton
fun provideHotCryptoRepository(
excludedBlockchains: ExcludedBlockchains,
hotCryptoResponseStore: HotCryptoResponseStore,
userWalletsStore: UserWalletsStore,
tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
analyticsEventHandler: AnalyticsEventHandler,
): HotCryptoRepository {
return DefaultHotCryptoRepository(
excludedBlockchains = excludedBlockchains,
hotCryptoResponseStore = hotCryptoResponseStore,
userWalletsStore = userWalletsStore,
tangemTechApi = tangemTechApi,
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
analyticsEventHandler = analyticsEventHandler,
)
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.data.onramp.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.onramp.models.response.Status
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.domain.core.serialization.SerializedBigDecimal
import com.tangem.domain.wallets.models.UserWalletId
/**
* Model for local storing onramp transaction
*
* @property txId Inner express tx id
* @property userWalletId Wallet where tx was performed
* @property fromAmount Onramp amount to buy crypto
* @property fromCurrency Onramp currency
* @property toAmount Receiving crypto amount
* @property toCurrencyId Receiving crypto currency id [CryptoCurrency.ID.value]
* @property status Transaction status
* @property externalTxUrl Link to transaction on provider side
* @property externalTxId Id of transaction on provider side
* @property timestamp Transaction create time
* @property providerName Provider name
* @property providerImageUrl Provider image link
* @property providerType Provider type
*/
@JsonClass(generateAdapter = true)
data class OnrampTransactionDTO(
@Json(name = "txId")
val txId: String,
@Json(name = "userWalletId")
val userWalletId: UserWalletId,
@Json(name = "fromAmount")
val fromAmount: SerializedBigDecimal,
@Json(name = "fromCurrency")
val fromCurrency: OnrampCurrencyDTO,
@Json(name = "toAmount")
val toAmount: SerializedBigDecimal,
@Json(name = "toCurrencyId")
val toCurrencyId: String,
@Json(name = "status")
val status: Status,
@Json(name = "externalTxUrl")
val externalTxUrl: String?,
@Json(name = "externalTxId")
val externalTxId: String?,
@Json(name = "timestamp")
val timestamp: Long,
@Json(name = "providerName")
val providerName: String, // todo onramp fix after SwapProvider moved to own module
@Json(name = "providerImageUrl")
val providerImageUrl: String, // todo onramp fix after SwapProvider moved to own module
@Json(name = "providerType")
val providerType: String, // todo onramp fix after SwapProvider moved to own module
@Json(name = "paymentMethod")
val paymentMethod: String,
@Json(name = "residency")
val residency: String,
)

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

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,28 @@
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.promo"
}
dependencies {
implementation(deps.androidx.datastore)
implementation(deps.jodatime)
implementation(deps.timber)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)
implementation(projects.domain.wallets.models)
implementation(projects.core.datasource)
implementation(projects.core.utils)
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.promo
import com.tangem.data.promo.converters.StoryContentResponseConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys.getShouldShowStoriesKey
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.promo.PromoStoriesStore
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.StoryContent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
internal class DefaultPromoRepository(
private val tangemApi: TangemTechApi,
private val appPreferencesStore: AppPreferencesStore,
private val promoStoriesStore: PromoStoriesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : PromoRepository {
private val storyContentConverter = StoryContentResponseConverter()
override fun isReadyToShowWalletSwapPromo(): Flow<Boolean> {
return flowOf(false) // Use it on new promo action
}
override fun isReadyToShowTokenSwapPromo(): Flow<Boolean> {
return flowOf(false) // Use it on new promo action
}
override suspend fun setNeverToShowWalletSwapPromo() {
// Use it on new promo action
}
override suspend fun setNeverToShowTokenSwapPromo() {
// Use it on new promo action
}
override fun getStoryById(id: String): Flow<StoryContent?> = isReadyToShowStories(id).mapLatest {
getStoryByIdSync(id = id, refresh = false)
}
override suspend fun getStoryByIdSync(id: String, refresh: Boolean): StoryContent? = withContext(dispatchers.io) {
if (!isReadyToShowStoriesSync(id)) return@withContext null
val storedPromo = promoStoriesStore.getSyncOrNull(storyId = id)
// Get last stored promo by id if possible or get from network
val story = if (storedPromo == null && refresh) {
val storyContent = runCatching {
// Important to return
withTimeoutOrNull(STORIES_LOAD_DELAY) {
tangemApi.getStoryById(storyId = id).getOrThrow()
}
}.getOrNull()
if (storyContent != null) {
promoStoriesStore.store(id, storyContent)
}
storyContent
} else {
storedPromo
}
story?.let { storyContentConverter.convert(it) }
}
override fun isReadyToShowStories(storyId: String): Flow<Boolean> {
return appPreferencesStore.get(getShouldShowStoriesKey(storyId), true)
}
override suspend fun isReadyToShowStoriesSync(storyId: String): Boolean {
return appPreferencesStore.getSyncOrDefault(getShouldShowStoriesKey(storyId), true)
}
override suspend fun setNeverToShowStories(storyId: String) {
appPreferencesStore.store(
key = getShouldShowStoriesKey(storyId),
value = false,
)
}
private companion object {
const val STORIES_LOAD_DELAY = 1000L
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.data.promo.converters
import com.tangem.datasource.api.promotion.models.PromotionInfoResponse
import com.tangem.domain.promo.models.PromoBanner
import com.tangem.utils.converter.Converter
import org.joda.time.DateTime
class PromoResponseConverter : Converter<PromotionInfoResponse, PromoBanner?> {
override fun convert(value: PromotionInfoResponse): PromoBanner? {
val bannerState = value.bannerState ?: return null
return PromoBanner(
name = value.name,
bannerState = PromoBanner.BannerState(
status = bannerState.status,
link = bannerState.link,
timeline = PromoBanner.Timeline(
start = DateTime.parse(bannerState.timeline.start),
end = DateTime.parse(bannerState.timeline.end),
),
),
)
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.data.promo.converters
import com.tangem.datasource.api.promotion.models.StoryContentResponse
import com.tangem.domain.promo.models.StoryContent
import com.tangem.utils.converter.Converter
internal class StoryContentResponseConverter : Converter<StoryContentResponse, StoryContent> {
override fun convert(value: StoryContentResponse): StoryContent {
return StoryContent(
imageHost = value.imageHost,
story = StoryContent.Story(
id = value.story.id,
title = value.story.title,
slides = value.story.slides?.map { slide ->
StoryContent.StorySlide(
id = slide.id,
)
}.orEmpty(),
),
)
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.data.promo.di
import com.tangem.data.promo.DefaultPromoRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.promo.PromoStoriesStore
import com.tangem.domain.promo.PromoRepository
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 PromoDataModule {
@Provides
@Singleton
fun providePromoRepository(
tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
promoStoriesStore: PromoStoriesStore,
dispatchers: CoroutineDispatcherProvider,
): PromoRepository {
return DefaultPromoRepository(
tangemApi = tangemTechApi,
appPreferencesStore = appPreferencesStore,
promoStoriesStore = promoStoriesStore,
dispatchers = dispatchers,
)
}
}

1
data/qr-scanning/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,34 @@
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.qrscanning"
}
dependencies {
/** Domain */
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.tokens.models)
implementation(projects.core.ui)
/** SdK */
implementation(tangemDeps.blockchain)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.qrscanning.di
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
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 QrScanningDataModule {
@Provides
@Singleton
fun provideQrScanningEventsRepository(): QrScanningEventsRepository {
return DefaultQrScanningEventsRepository()
}
}

View file

@ -0,0 +1,152 @@
package com.tangem.data.qrscanning.repository
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.qrscanning.models.QrResult
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import com.tangem.domain.tokens.model.CryptoCurrency
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.yield
import java.math.BigDecimal
import java.net.URLDecoder
internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
private data class QrScanningEvent(val type: SourceType, val qrCode: String)
private val scannedEvents = MutableSharedFlow<QrScanningEvent>(replay = 1)
override suspend fun emitResult(type: SourceType, qrCode: String) {
scannedEvents.emit(QrScanningEvent(type, qrCode))
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun subscribeToScanningResults(type: SourceType) = scannedEvents
.filter { it.type == type }
.map { it.qrCode }
.onEach {
yield() // if we have more than one sub, we must allow them to collect emitted value
scannedEvents.resetReplayCache()
}
override fun parseQrCode(qrCode: String, cryptoCurrency: CryptoCurrency): QrResult {
val withoutSchema = stripSchema(qrCode, cryptoCurrency)
// A poor man's ERC-681 parser: we want to extract only the destination address, and we don't care
// about other parts of the ERC-681 payload string like `chain_id` and/or `function_name`.
//
// We're extracting the destination address by parsing the given string until we meet
// any of the possible string delimiters (@ ? /).
val address = withoutSchema.takeWhile { char ->
char != CHAIN_DELIMITER && char != FUNCTION_DELIMITER && char != PARAM_DELIMITER
}
val result = QrResult(address = address)
extractParameters(withoutSchema)
.forEach {
when (it.key) {
Parameter.Amount -> {
// According to BIP-0021, the value is specified in decimals. No conversion needed
result.amount = it.value.parseBigDecimalOrNull()
}
Parameter.Message,
Parameter.Memo,
-> {
result.memo = URLDecoder.decode(it.value, "UTF-8")
}
Parameter.Address -> {
// If 'address' parameter is exists, then currency must be TOKEN.
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
// Overrides destination address for token transfers (ERC-681)
// `address` parameter is used only if the contract address, encoded in the QR,
// matches the contract address of the token.
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routin
if (tokenCurrency.contractAddress.equals(address, ignoreCase = true)) {
result.address = it.value
} else {
return QrResult()
}
}
Parameter.Value,
Parameter.Uint256,
-> {
// Extra convert parses scientific notation to decimal
// This is necessary to be able comparing BigDecimal values
result.amount = it.value.parseBigDecimalOrNull()
?.toPlainString()?.toBigDecimalOrNull()
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
}
}
}
return result
}
private fun stripSchema(raw: String, currency: CryptoCurrency): String {
val qrSchemas = Blockchain.fromId(currency.network.id.value).getShareScheme()
// The most specific (i.e. the most lengthy) prefixes always come first
qrSchemas
.sortedByDescending { it.length }
.forEach { schema ->
val stripped = raw.split(schema)
if (stripped.size > 1) return stripped.last()
}
return raw
}
private fun extractParameters(from: String): Map<Parameter, String> {
val parametersBlock = from.substringAfter(PARAM_DELIMITER)
if (parametersBlock.isBlank()) return emptyMap()
val paramList = parametersBlock.split(PARAMS_DELIMITER)
.mapNotNull { param ->
val parameterWithValue = param.split(PARAM_VALUE_DELIMITER)
if (parameterWithValue.size == 2) {
val name = Parameter.getParam(parameterWithValue.first())
val value = parameterWithValue.last()
if (name != null) {
name to value
} else {
null
}
} else {
null
}
}.associate { it }
return paramList
}
private enum class Parameter {
Amount,
Message,
Memo,
Address,
Value,
Uint256,
;
companion object {
fun getParam(name: String): Parameter? {
return Parameter.entries.firstOrNull { it.name.equals(name, ignoreCase = true) }
}
}
}
private companion object {
// See https://eips.ethereum.org/EIPS/eip-681 for details.
const val CHAIN_DELIMITER = '@' // ERC-681 [ "@" chain_id ]
const val FUNCTION_DELIMITER = '/' // ERC-681 [ "/" function_name ]
const val PARAM_DELIMITER = '?' // BIP-021, ERC-681
const val PARAMS_DELIMITER = '&'
const val PARAM_VALUE_DELIMITER = '='
}
}

View file

@ -0,0 +1,263 @@
package com.tangem.data.qrscanning
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository
import com.tangem.domain.qrscanning.models.QrResult
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import java.math.BigDecimal
internal class DefaultQrScanningEventsRepositoryTest {
private val repository = DefaultQrScanningEventsRepository()
private val cryptoCurrencyId = mockk<CryptoCurrency.ID>()
private val network = mockk<Network>()
private val cryptoCurrency = CryptoCurrency.Coin(
id = cryptoCurrencyId,
network = network,
name = "blockchain",
symbol = "symbol",
decimals = 18,
iconUrl = null,
isCustom = false,
)
private val tokenCryptoCurrency = CryptoCurrency.Token(
id = cryptoCurrencyId,
network = network,
name = "blockchain",
symbol = "symbol",
decimals = 7,
iconUrl = null,
isCustom = false,
contractAddress = "0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7",
)
private val garbage = "some_garbage="
private val schema1 = "bitcoin"
private val schema2 = "ethereum"
private val address1 = "bc1pw83rs5s75na2g7ec8yqgekr3ae209ye7ck2ftakjnh8tv3xzw8ls6wgt62"
private val address2 = "0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"
private val address3 = "pay-0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"
private val address4 = "0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7"
private val function = "/transfer"
private val someParam = "someParam"
private val someParamValue = "someParamValue"
private val addressParam = "address"
private val addressParamValue = "0xc00f86ab93cd0bd3a60213583d0fe35aaa1ace23"
private val amountParam = "amount"
private val someAmountParamValue = "amount"
private val amountParamValue = "123.123"
private val valueParam = "value"
private val someValueParamValue = "amount"
private val valueParamValue2 = "1.88e10"
private val valueParamValue3 = "1.68E11"
private val valueParamValue4 = "23000000000"
private val memoParam = "memo"
private val memoParamValue = "a%20random%20memo"
private val memoParamValueUtf8 = "a random memo"
private val messageParam = "message"
private val messageParamValue = "some%20message"
private val messageParamValueUft8 = "some message"
private val messageParamValue2 = "message"
@Test
fun testBip021() {
every { network.id } returns Network.ID(Blockchain.Bitcoin.id)
positiveCase(
"$schema1:$address1",
QrResult(address = address1),
cryptoCurrency,
)
positiveCase(
"$garbage$schema1:$address1",
QrResult(address = address1),
cryptoCurrency,
)
positiveCase(
"$address1?$someParam=$someParamValue",
QrResult(address = address1),
cryptoCurrency,
)
positiveCase(
"$address1?$someParam=$someParamValue&$amountParam=$someAmountParamValue",
QrResult(address = address1),
cryptoCurrency,
)
positiveCase(
"$address1?$someParam=$someParamValue&$amountParam=$amountParamValue",
QrResult(address = address1, amount = BigDecimal(amountParamValue)),
cryptoCurrency,
)
positiveCase(
"$address1?$someParam=$someParamValue&$amountParam=$amountParamValue&$memoParam=$memoParamValue",
QrResult(address = address1, amount = BigDecimal(amountParamValue), memo = memoParamValueUtf8),
cryptoCurrency,
)
positiveCase(
"$address1?$someParam=$someParamValue&$messageParam=$messageParamValue",
QrResult(address = address1, memo = messageParamValueUft8),
cryptoCurrency,
)
positiveCase(
"$address1?$someParam=$someParamValue&$messageParam=$messageParamValue2",
QrResult(address = address1, memo = messageParamValue2),
cryptoCurrency,
)
negativeCase(
"$address1?$someParam=$someParamValue&$amountParam=$amountParamValue",
QrResult(address = address1, memo = messageParamValue2),
cryptoCurrency,
)
}
@Test
fun testErc681Coin() {
every { network.id } returns Network.ID(Blockchain.Ethereum.id)
positiveCase(
address2,
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$address2?$someParam=$someParamValue",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$schema2:$address2",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$schema2:$address3",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$someAmountParamValue",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$valueParamValue2",
QrResult(address = address2, amount = BigDecimal("0.0000000188")),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$valueParamValue3",
QrResult(address = address2, amount = BigDecimal("0.000000168")),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$valueParamValue4",
QrResult(address = address2, amount = BigDecimal("0.000000023")),
cryptoCurrency,
)
negativeCase(
"$garbage$schema2:$address2$function?$addressParam=$addressParamValue",
QrResult(address = address2),
cryptoCurrency,
)
}
@Test
fun testErc681Token() {
every { network.id } returns Network.ID(Blockchain.Ethereum.id)
positiveCase(
address2,
QrResult(address = address2),
tokenCryptoCurrency,
)
positiveCase(
"$address2?$someParam=$someParamValue",
QrResult(address = address2),
tokenCryptoCurrency,
)
positiveCase(
"$schema2:$address2",
QrResult(address = address2),
tokenCryptoCurrency,
)
positiveCase(
"$schema2:$address3",
QrResult(address = address2),
tokenCryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2",
QrResult(address = address2),
tokenCryptoCurrency,
)
positiveCase(
"$schema2:$address4$function?$addressParam=$addressParamValue",
QrResult(address = addressParamValue),
tokenCryptoCurrency,
)
positiveCase(
"$address2?$someParam=$someParamValue&$valueParam=$someValueParamValue",
QrResult(address = address2),
tokenCryptoCurrency,
)
positiveCase(
"$address2?$someParam=$someParamValue&$valueParam=$valueParamValue2",
QrResult(address = address2, amount = BigDecimal("1880")),
tokenCryptoCurrency,
)
positiveCase(
"$address2?$someParam=$someParamValue&$valueParam=$valueParamValue3",
QrResult(address = address2, amount = BigDecimal("16800")),
tokenCryptoCurrency,
)
positiveCase(
"$address2?$someParam=$someParamValue&$valueParam=$valueParamValue4",
QrResult(address = address2, amount = BigDecimal("2300")),
tokenCryptoCurrency,
)
positiveCase(
"$address4?$addressParam=$addressParamValue",
QrResult(address = addressParamValue),
tokenCryptoCurrency,
)
negativeCase(
"$address2?$someParam=$someParamValue&$amountParam=$amountParamValue",
QrResult(address = address2, amount = BigDecimal("123.123"), memo = memoParamValueUtf8),
tokenCryptoCurrency,
)
negativeCase(
"$address4?$addressParam=$addressParamValue",
QrResult(address = addressParamValue),
cryptoCurrency,
)
}
private fun positiveCase(input: String, expected: QrResult, cryptoCurrency: CryptoCurrency) {
val actual = repository.parseQrCode(input, cryptoCurrency)
Truth.assertThat(actual.address).isEqualTo(expected.address)
Truth.assertThat(actual.amount).isEqualTo(expected.amount)
Truth.assertThat(actual.memo).isEqualTo(expected.memo)
}
private fun negativeCase(input: String, expected: QrResult, cryptoCurrency: CryptoCurrency) {
val actual = repository.parseQrCode(input, cryptoCurrency)
Truth.assertThat(actual).isNotEqualTo(expected)
}
}

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

@ -0,0 +1 @@
/build

Some files were not shown because too many files have changed in this diff Show more