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