Updated on 2026-08-14
This commit is contained in:
commit
a22e77ebe8
669 changed files with 27695 additions and 7905 deletions
20
data/appsflyer/build.gradle.kts
Normal file
20
data/appsflyer/build.gradle.kts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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.appsflyer"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.datasource)
|
||||
|
||||
implementation(projects.domain.appsflyer)
|
||||
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.data.appsflyer
|
||||
|
||||
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
||||
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
|
||||
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
|
||||
import javax.inject.Inject
|
||||
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource as StoreDeeplinkSource
|
||||
|
||||
internal class DefaultAppsFlyerRepository @Inject constructor(
|
||||
private val appsFlyerStore: AppsFlyerStore,
|
||||
) : AppsFlyerRepository {
|
||||
|
||||
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
|
||||
appsFlyerStore.clearDeeplink(source.toStoreSource())
|
||||
}
|
||||
|
||||
private fun AppsFlyerDeeplinkSource.toStoreSource() = when (this) {
|
||||
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> StoreDeeplinkSource.TangemPayHotWalletOnboarding
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.tangem.data.appsflyer.di
|
||||
|
||||
import com.tangem.data.appsflyer.DefaultAppsFlyerRepository
|
||||
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
|
||||
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface AppsFlyerDataModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindAppsFlyerRepository(repository: DefaultAppsFlyerRepository): AppsFlyerRepository
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
fun provideClearAppsFlyerDeeplinkUseCase(
|
||||
appsFlyerRepository: AppsFlyerRepository,
|
||||
): ClearAppsFlyerDeeplinkUseCase {
|
||||
return ClearAppsFlyerDeeplinkUseCase(appsFlyerRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,9 +29,7 @@ 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.onramp.sepa.OnrampCurrentCountryByIPStore
|
||||
import com.tangem.datasource.local.onramp.sepa.OnrampSepaAvailabilityStore
|
||||
import com.tangem.datasource.local.onramp.sepa.OnrampSepaAvailabilityStoreKey
|
||||
import com.tangem.datasource.local.onramp.country.OnrampCurrentCountryByIPStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObject
|
||||
|
|
@ -67,7 +65,6 @@ internal class DefaultOnrampRepository(
|
|||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val paymentMethodsStore: OnrampPaymentMethodsStore,
|
||||
private val onrampSepaAvailabilityStore: OnrampSepaAvailabilityStore,
|
||||
private val onrampCurrentCountryByIPStore: OnrampCurrentCountryByIPStore,
|
||||
private val pairsStore: OnrampPairsStore,
|
||||
private val quotesStore: OnrampQuotesStore,
|
||||
|
|
@ -281,62 +278,6 @@ internal class DefaultOnrampRepository(
|
|||
storeOnrampPairs(pairs = onrampPairs.await(), providers = providers.await())
|
||||
}
|
||||
|
||||
override suspend fun hasSepaMethod(
|
||||
userWallet: UserWallet,
|
||||
country: OnrampCountry,
|
||||
cryptoCurrency: CryptoCurrency,
|
||||
): Boolean {
|
||||
return withContext(dispatchers.io) {
|
||||
val key = OnrampSepaAvailabilityStoreKey(
|
||||
userWallet = userWallet,
|
||||
country = country,
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
)
|
||||
|
||||
val isCachedValue = onrampSepaAvailabilityStore.getSyncOrNull(key)
|
||||
|
||||
if (isCachedValue != null) {
|
||||
return@withContext isCachedValue
|
||||
}
|
||||
|
||||
val onrampPairs =
|
||||
safeApiCall(
|
||||
call = {
|
||||
onrampApi.getPairs(
|
||||
userWalletId = userWallet.walletId.stringValue,
|
||||
refCode = ExpressUtils.getRefCode(
|
||||
userWallet = userWallet,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
),
|
||||
body = OnrampPairsRequest(
|
||||
fromCurrencyCode = EUR_CURRENCY_CODE,
|
||||
countryCode = country.code,
|
||||
to = listOf(
|
||||
OnrampDestinationDTO(
|
||||
contractAddress = cryptoCurrency.getContractAddress(),
|
||||
network = cryptoCurrency.network.rawId,
|
||||
),
|
||||
),
|
||||
),
|
||||
).bind()
|
||||
},
|
||||
onError = { error ->
|
||||
TangemLogger.w("Unable to fetch onramp pairs", error)
|
||||
throw error
|
||||
},
|
||||
)
|
||||
|
||||
val hasSepaMethod = onrampPairs
|
||||
.flatMap { it.providers }
|
||||
.flatMap { it.paymentMethods }
|
||||
.any { it == SEPA_METHOD_ID }
|
||||
|
||||
onrampSepaAvailabilityStore.store(key, hasSepaMethod)
|
||||
|
||||
hasSepaMethod
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchQuotes(userWallet: UserWallet, cryptoCurrency: CryptoCurrency, amount: Amount) =
|
||||
withContext(dispatchers.io) {
|
||||
val pairs = requireNotNull(pairsStore.getSyncOrNull(PAIRS_KEY)) {
|
||||
|
|
@ -632,8 +573,5 @@ internal class DefaultOnrampRepository(
|
|||
const val PROVIDER_THEME_LIGHT = "light"
|
||||
|
||||
const val REDIRECT_URL = "https://tangem.com/onramp"
|
||||
|
||||
const val SEPA_METHOD_ID = "sepa"
|
||||
const val EUR_CURRENCY_CODE = "EUR"
|
||||
}
|
||||
}
|
||||
|
|
@ -24,8 +24,7 @@ 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.onramp.sepa.OnrampCurrentCountryByIPStore
|
||||
import com.tangem.datasource.local.onramp.sepa.OnrampSepaAvailabilityStore
|
||||
import com.tangem.datasource.local.onramp.country.OnrampCurrentCountryByIPStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
|
|
@ -56,7 +55,6 @@ internal object OnrampDataModule {
|
|||
currenciesStore: OnrampCurrenciesStore,
|
||||
walletManagersFacade: WalletManagersFacade,
|
||||
dataSignatureVerifier: DataSignatureVerifier,
|
||||
onrampSepaAvailabilityStore: OnrampSepaAvailabilityStore,
|
||||
onrampCurrentCountryByIPStore: OnrampCurrentCountryByIPStore,
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
): OnrampRepository {
|
||||
|
|
@ -66,7 +64,6 @@ internal object OnrampDataModule {
|
|||
dispatchers = dispatchers,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
paymentMethodsStore = paymentMethodsStore,
|
||||
onrampSepaAvailabilityStore = onrampSepaAvailabilityStore,
|
||||
onrampCurrentCountryByIPStore = onrampCurrentCountryByIPStore,
|
||||
pairsStore = pairsStore,
|
||||
quotesStore = quotesStore,
|
||||
|
|
|
|||
|
|
@ -1,203 +0,0 @@
|
|||
package com.tangem.data.promo
|
||||
|
||||
import com.tangem.data.promo.converters.PromoBannerConverter
|
||||
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
|
||||
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.PromoBannerStore
|
||||
import com.tangem.datasource.local.promo.PromoStoriesStore
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.promo.PromoRepository
|
||||
import com.tangem.domain.promo.models.PromoBanner
|
||||
import com.tangem.domain.promo.models.PromoId
|
||||
import com.tangem.domain.promo.models.StoryContent
|
||||
import com.tangem.feature.referral.domain.ReferralRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runCatching
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import kotlinx.coroutines.flow.*
|
||||
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 promoBannerStore: PromoBannerStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val referralRepository: ReferralRepository,
|
||||
) : PromoRepository {
|
||||
|
||||
private val storyContentConverter = StoryContentResponseConverter()
|
||||
private val promoBannerConverter = PromoBannerConverter()
|
||||
|
||||
override fun isReadyToShowWalletPromo(userWalletId: UserWalletId, promoId: PromoId): Flow<Boolean> {
|
||||
return appPreferencesStore.get(
|
||||
key = PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name),
|
||||
default = true,
|
||||
)
|
||||
.distinctUntilChanged()
|
||||
.map { shouldShow ->
|
||||
when (promoId) {
|
||||
PromoId.Referral -> runSuspendCatching {
|
||||
!referralRepository.isReferralParticipant(userWalletId) && shouldShow
|
||||
}.getOrDefault(false)
|
||||
PromoId.Sepa -> {
|
||||
val isActive = getSepaPromoBanner()?.isActive == true
|
||||
|
||||
isActive && shouldShow
|
||||
}
|
||||
PromoId.VisaPresale -> {
|
||||
val isActive = getVisaPromoBanner()?.isActive == true
|
||||
|
||||
isActive && shouldShow
|
||||
}
|
||||
PromoId.BlackFriday -> {
|
||||
val isActive = getBlackFridayPromoBanner()?.isActive == true
|
||||
|
||||
isActive && shouldShow
|
||||
}
|
||||
PromoId.OnePlusOne -> {
|
||||
val isActive = getOnePlusOnePromoBanner()?.isActive == true
|
||||
|
||||
isActive && shouldShow
|
||||
}
|
||||
PromoId.YieldPromo -> {
|
||||
val isActive = getYieldPromoBanner(userWalletId)?.isActive == true
|
||||
|
||||
isActive && shouldShow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isReadyToShowTokenPromo(promoId: PromoId): Flow<Boolean> {
|
||||
return when (promoId) {
|
||||
PromoId.Referral -> flowOf(false)
|
||||
PromoId.Sepa -> flowOf(false)
|
||||
PromoId.VisaPresale -> flowOf(false)
|
||||
PromoId.BlackFriday -> flowOf(false)
|
||||
PromoId.OnePlusOne -> flowOf(false)
|
||||
PromoId.YieldPromo -> flowOf(false)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setNeverToShowWalletPromo(promoId: PromoId) {
|
||||
appPreferencesStore.store(PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name), false)
|
||||
}
|
||||
|
||||
override suspend fun setNeverToShowTokenPromo(promoId: PromoId) {
|
||||
appPreferencesStore.store(PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name), false)
|
||||
}
|
||||
|
||||
override suspend fun isMoonpayPromoActive(): Boolean {
|
||||
val banner = runCatching(dispatchers.io) {
|
||||
val response = promoBannerStore.getSyncOrNull(MOONPAY_NAME) ?: run {
|
||||
val apiResponse = tangemApi.getPromoBanner(MOONPAY_NAME).getOrThrow()
|
||||
promoBannerStore.store(MOONPAY_NAME, apiResponse)
|
||||
apiResponse
|
||||
}
|
||||
promoBannerConverter.convert(response)
|
||||
}.getOrNull()
|
||||
return banner?.isActive == true
|
||||
}
|
||||
|
||||
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 = runSuspendCatching {
|
||||
// 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 suspend fun getSepaPromoBanner(): PromoBanner? {
|
||||
return runCatching(dispatchers.io) {
|
||||
promoBannerConverter.convert(
|
||||
tangemApi.getPromoBanner(SEPA_NAME).getOrThrow(),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun getVisaPromoBanner(): PromoBanner? {
|
||||
return runCatching(dispatchers.io) {
|
||||
promoBannerConverter.convert(
|
||||
tangemApi.getPromoBanner(VISA_NAME).getOrThrow(),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun getBlackFridayPromoBanner(): PromoBanner? {
|
||||
return runCatching(dispatchers.io) {
|
||||
promoBannerConverter.convert(
|
||||
tangemApi.getPromoBanner(BLACK_FRIDAY_NAME).getOrThrow(),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun getOnePlusOnePromoBanner(): PromoBanner? {
|
||||
return runCatching(dispatchers.io) {
|
||||
promoBannerConverter.convert(
|
||||
tangemApi.getPromoBanner(ONE_PLUS_ONE_NAME).getOrThrow(),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun getYieldPromoBanner(userWalletId: UserWalletId): PromoBanner? {
|
||||
return runCatching(dispatchers.io) {
|
||||
val response = tangemApi.getPromoBannersV2(userWalletId.stringValue).getOrThrow()
|
||||
val yieldPromotion = response.promotions.find { it.name == YIELD_PROMO_NAME }
|
||||
?: return@runCatching null
|
||||
promoBannerConverter.convert(yieldPromotion)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SEPA_NAME = "sepa"
|
||||
const val VISA_NAME = "visa-waitlist"
|
||||
const val BLACK_FRIDAY_NAME = "black-friday"
|
||||
const val MOONPAY_NAME = "moonpay"
|
||||
const val ONE_PLUS_ONE_NAME = "one-plus-one"
|
||||
const val YIELD_PROMO_NAME = "promo-yield"
|
||||
const val STORIES_LOAD_DELAY = 1000L
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
package com.tangem.data.promo.converters
|
||||
|
||||
import com.tangem.datasource.api.promotion.models.PromoBannerResponse
|
||||
import com.tangem.domain.promo.models.PromoBanner
|
||||
import com.tangem.utils.converter.Converter
|
||||
import org.joda.time.DateTime
|
||||
|
||||
class PromoBannerConverter : Converter<PromoBannerResponse, PromoBanner?> {
|
||||
|
||||
override fun convert(value: PromoBannerResponse): 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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
39
data/push-notification-preferences/build.gradle.kts
Normal file
39
data/push-notification-preferences/build.gradle.kts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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.pushnotificationpreferences"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Domain */
|
||||
implementation(projects.domain.pushNotificationPreferences)
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
/** Tests */
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.turbine)
|
||||
testImplementation(deps.moshi)
|
||||
testImplementation(deps.moshi.kotlin)
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package com.tangem.data.pushnotificationpreferences
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* In-memory cache implementation of [WalletPushNotificationPreferencesRepository].
|
||||
*
|
||||
* Mock-mode (current): defaults are computed locally and writes are kept in-memory only.
|
||||
* Real-mode (when Variant C BE is ready): replace TODO blocks with [TangemTechApi] calls.
|
||||
*
|
||||
* Defaults for existing users (until BE migration runs): TX read from
|
||||
* [PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY] (default true), Offers&Updates = true, Price Alerts = false,
|
||||
* isVisible = true for all three.
|
||||
*/
|
||||
internal class DefaultWalletPushNotificationPreferencesRepository(
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
@Suppress("unused") private val tangemTechApi: TangemTechApi,
|
||||
private val cache: RuntimeSharedStore<Map<String, WalletPushNotificationPreferences>>,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : WalletPushNotificationPreferencesRepository {
|
||||
|
||||
override suspend fun preload(userWalletId: UserWalletId) {
|
||||
if (cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true) return
|
||||
val preferences = withContext(dispatchers.io) {
|
||||
// TODO: uncomment when api is ready
|
||||
// val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow()
|
||||
// PushNotificationPreferencesConverter.convert(response)
|
||||
loadDefaults(userWalletId)
|
||||
}
|
||||
cache.update(default = emptyMap()) { current ->
|
||||
if (current.containsKey(userWalletId.stringValue)) {
|
||||
current
|
||||
} else {
|
||||
current + (userWalletId.stringValue to preferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun observePreferences(userWalletId: UserWalletId): Flow<WalletPushNotificationPreferences> = cache.get()
|
||||
.onStart { preload(userWalletId) }
|
||||
.map { it[userWalletId.stringValue] }
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
|
||||
override suspend fun updatePreference(
|
||||
userWalletId: UserWalletId,
|
||||
category: PushNotificationCategory,
|
||||
isEnabled: Boolean,
|
||||
): Either<Throwable, Unit> = Either.catch {
|
||||
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
|
||||
val updated = applyCategory(current, category, isEnabled)
|
||||
withContext(dispatchers.io) {
|
||||
// TODO: uncomment when api is ready
|
||||
// tangemTechApi.updatePushNotificationPreferences(
|
||||
// walletId = userWalletId.stringValue,
|
||||
// body = PushNotificationPreferencesBody(
|
||||
// areTransactionAlertsEnabled = updated.transactionAlerts.isEnabled,
|
||||
// areOffersUpdatesEnabled = updated.offersUpdates.isEnabled,
|
||||
// arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
|
||||
// ),
|
||||
// ).getOrThrow()
|
||||
}
|
||||
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to updated) }
|
||||
}
|
||||
|
||||
private fun applyCategory(
|
||||
current: WalletPushNotificationPreferences,
|
||||
category: PushNotificationCategory,
|
||||
isEnabled: Boolean,
|
||||
): WalletPushNotificationPreferences = when (category) {
|
||||
PushNotificationCategory.TransactionAlerts -> current.copy(
|
||||
transactionAlerts = current.transactionAlerts.copy(isEnabled = isEnabled),
|
||||
)
|
||||
PushNotificationCategory.OffersUpdates -> current.copy(
|
||||
offersUpdates = current.offersUpdates.copy(isEnabled = isEnabled),
|
||||
)
|
||||
PushNotificationCategory.PriceAlerts -> current.copy(
|
||||
priceAlerts = current.priceAlerts.copy(isEnabled = isEnabled),
|
||||
)
|
||||
}
|
||||
|
||||
// TODO remove when api is ready, use api methods to load real settings
|
||||
private suspend fun loadDefaults(userWalletId: UserWalletId): WalletPushNotificationPreferences {
|
||||
val areTransactionAlertsEnabled = appPreferencesStore
|
||||
.getObjectMapSync<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)[userWalletId.stringValue] !=
|
||||
false
|
||||
return WalletPushNotificationPreferences(
|
||||
transactionAlerts = PushNotificationPreference(isEnabled = areTransactionAlertsEnabled, isVisible = true),
|
||||
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
|
||||
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.tangem.data.pushnotificationpreferences.converters
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferenceState
|
||||
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal object PushNotificationPreferencesConverter :
|
||||
Converter<PushNotificationPreferencesResponse, WalletPushNotificationPreferences> {
|
||||
|
||||
override fun convert(value: PushNotificationPreferencesResponse): WalletPushNotificationPreferences =
|
||||
WalletPushNotificationPreferences(
|
||||
transactionAlerts = value.transactionAlerts.toDomain(),
|
||||
offersUpdates = value.offersUpdates.toDomain(),
|
||||
priceAlerts = value.priceAlerts.toDomain(),
|
||||
)
|
||||
|
||||
private fun PushNotificationPreferenceState.toDomain(): PushNotificationPreference =
|
||||
PushNotificationPreference(isEnabled = isEnabled, isVisible = isVisible)
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.tangem.data.pushnotificationpreferences.di
|
||||
|
||||
import com.tangem.data.pushnotificationpreferences.DefaultWalletPushNotificationPreferencesRepository
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
|
||||
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 PushNotificationPreferencesModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun providesWalletPushNotificationPreferencesRepository(
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
tangemTechApi: TangemTechApi,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): WalletPushNotificationPreferencesRepository = DefaultWalletPushNotificationPreferencesRepository(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
tangemTechApi = tangemTechApi,
|
||||
cache = RuntimeSharedStore(),
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
package com.tangem.data.pushnotificationpreferences
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import app.cash.turbine.test
|
||||
import arrow.core.Either
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultWalletPushNotificationPreferencesRepositoryTest {
|
||||
|
||||
private val tangemTechApi: TangemTechApi = mockk()
|
||||
private val preferencesDataStore: DataStore<Preferences> = mockk()
|
||||
private val appPreferencesStore = AppPreferencesStore(
|
||||
moshi = Moshi.Builder().build(),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
preferencesDataStore = preferencesDataStore,
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "0011223344556677")
|
||||
private val otherWalletId = UserWalletId(stringValue = "ffeeddccbbaa9988")
|
||||
|
||||
private val repository = DefaultWalletPushNotificationPreferencesRepository(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
tangemTechApi = tangemTechApi,
|
||||
cache = RuntimeSharedStore(),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN no prior state WHEN preload THEN cache contains defaults`() = runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
|
||||
repository.preload(userWalletId)
|
||||
|
||||
repository.observePreferences(userWalletId).test {
|
||||
assertThat(awaitItem()).isEqualTo(defaults(transactionAlertsEnabled = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN preload already done WHEN preload called again THEN no-op`() = runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
|
||||
repository.preload(userWalletId)
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
|
||||
repository.preload(userWalletId)
|
||||
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.offersUpdates.isEnabled).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cache miss WHEN updatePreference THEN loads defaults and applies update`() = runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
|
||||
val result = repository.updatePreference(
|
||||
userWalletId = userWalletId,
|
||||
category = PushNotificationCategory.PriceAlerts,
|
||||
isEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(Either.Right::class.java)
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.priceAlerts.isEnabled).isTrue()
|
||||
assertThat(item.offersUpdates.isEnabled).isTrue()
|
||||
assertThat(item.transactionAlerts.isEnabled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN preloaded state WHEN updatePreference for each category THEN updates only that category`() = runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
|
||||
repository.preload(userWalletId)
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, isEnabled = false)
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
|
||||
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.transactionAlerts.isEnabled).isFalse()
|
||||
assertThat(item.offersUpdates.isEnabled).isFalse()
|
||||
assertThat(item.priceAlerts.isEnabled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no subscription yet WHEN observePreferences subscribed THEN triggers preload and emits defaults`() =
|
||||
runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item).isEqualTo(defaults(transactionAlertsEnabled = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN updates for different wallets WHEN observed independently THEN each wallet has its own state`() =
|
||||
runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
|
||||
repository.updatePreference(otherWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
|
||||
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.offersUpdates.isEnabled).isFalse()
|
||||
assertThat(item.priceAlerts.isEnabled).isFalse()
|
||||
}
|
||||
repository.observePreferences(otherWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.offersUpdates.isEnabled).isTrue()
|
||||
assertThat(item.priceAlerts.isEnabled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaults(transactionAlertsEnabled: Boolean) = WalletPushNotificationPreferences(
|
||||
transactionAlerts = PushNotificationPreference(isEnabled = transactionAlertsEnabled, isVisible = true),
|
||||
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
|
||||
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
|
||||
)
|
||||
}
|
||||
|
|
@ -117,6 +117,7 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
|
|||
private fun convertPeriod(periodDTO: YieldDTO.MetadataDTO.PeriodDTO): Yield.Metadata.Period {
|
||||
return Yield.Metadata.Period(
|
||||
days = periodDTO.days.asMandatory("days"),
|
||||
seconds = periodDTO.seconds,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,19 +7,17 @@ plugins {
|
|||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.promo"
|
||||
namespace = "com.tangem.data.stories"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
implementation(deps.jodatime)
|
||||
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
implementation(projects.domain.promo)
|
||||
implementation(projects.domain.promo.models)
|
||||
implementation(projects.domain.stories)
|
||||
implementation(projects.domain.stories.models)
|
||||
api(projects.domain.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.features.referral.domain)
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package com.tangem.data.stories
|
||||
|
||||
import com.tangem.data.stories.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
|
||||
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.stories.StoriesStore
|
||||
import com.tangem.domain.stories.StoriesRepository
|
||||
import com.tangem.domain.stories.models.StoryContent
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
internal class DefaultStoriesRepository(
|
||||
private val tangemApi: TangemTechApi,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val promoStoriesStore: StoriesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : StoriesRepository {
|
||||
|
||||
private val storyContentConverter = StoryContentResponseConverter()
|
||||
|
||||
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 = runSuspendCatching {
|
||||
// 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(PreferencesKeys.getShouldShowStoriesKey(storyId), true)
|
||||
}
|
||||
|
||||
override suspend fun isReadyToShowStoriesSync(storyId: String): Boolean {
|
||||
return appPreferencesStore.getSyncOrDefault(PreferencesKeys.getShouldShowStoriesKey(storyId), true)
|
||||
}
|
||||
|
||||
override suspend fun setNeverToShowStories(storyId: String) {
|
||||
appPreferencesStore.store(
|
||||
key = PreferencesKeys.getShouldShowStoriesKey(storyId),
|
||||
value = false,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STORIES_LOAD_DELAY = 1000L
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package com.tangem.data.promo.converters
|
||||
package com.tangem.data.stories.converters
|
||||
|
||||
import com.tangem.datasource.api.promotion.models.StoryContentResponse
|
||||
import com.tangem.domain.promo.models.StoryContent
|
||||
import com.tangem.datasource.api.stories.models.StoryContentResponse
|
||||
import com.tangem.domain.stories.models.StoryContent
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class StoryContentResponseConverter : Converter<StoryContentResponse, StoryContent> {
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
package com.tangem.data.promo.di
|
||||
package com.tangem.data.stories.di
|
||||
|
||||
import com.tangem.data.promo.DefaultPromoRepository
|
||||
import com.tangem.data.stories.DefaultStoriesRepository
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.promo.PromoBannerStore
|
||||
import com.tangem.datasource.local.promo.PromoStoriesStore
|
||||
import com.tangem.domain.promo.PromoRepository
|
||||
import com.tangem.feature.referral.domain.ReferralRepository
|
||||
import com.tangem.datasource.local.stories.StoriesStore
|
||||
import com.tangem.domain.stories.StoriesRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
@ -16,25 +14,21 @@ import javax.inject.Singleton
|
|||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object PromoDataModule {
|
||||
internal object StoriesDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePromoRepository(
|
||||
fun provideStoriesRepository(
|
||||
tangemTechApi: TangemTechApi,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
promoStoriesStore: PromoStoriesStore,
|
||||
promoBannerStore: PromoBannerStore,
|
||||
promoStoriesStore: StoriesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
referralRepository: ReferralRepository,
|
||||
): PromoRepository {
|
||||
return DefaultPromoRepository(
|
||||
): StoriesRepository {
|
||||
return DefaultStoriesRepository(
|
||||
tangemApi = tangemTechApi,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
promoStoriesStore = promoStoriesStore,
|
||||
dispatchers = dispatchers,
|
||||
referralRepository = referralRepository,
|
||||
promoBannerStore = promoBannerStore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
|
|||
is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty()
|
||||
is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount(
|
||||
fiatBalance = value.fiatBalance.toDM(),
|
||||
cryptoBalance = value.cryptoBalance.toDM(),
|
||||
)
|
||||
// Transient statuses are not persisted
|
||||
is PaymentAccountStatusValue.Loading,
|
||||
|
|
@ -72,6 +73,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
|
|||
}
|
||||
|
||||
fun convertBack(userWalletId: UserWalletId, value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue {
|
||||
val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId)
|
||||
return when (value) {
|
||||
is PaymentAccountStatusValueDM.Empty -> PaymentAccountStatusValue.Empty
|
||||
is PaymentAccountStatusValueDM.NotCreated -> PaymentAccountStatusValue.NotCreated
|
||||
|
|
@ -89,7 +91,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
|
|||
fiatBalance = value.fiatBalance.toDomain(),
|
||||
cryptoBalance = value.cryptoBalance.toDomain(),
|
||||
availableForWithdrawal = value.availableForWithdrawal,
|
||||
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
cards = value.cards.map { card ->
|
||||
TangemPayCard(
|
||||
id = card.id,
|
||||
|
|
@ -117,6 +119,8 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
|
|||
is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated(
|
||||
source = StatusSource.CACHE,
|
||||
fiatBalance = value.fiatBalance.toDomain(),
|
||||
cryptoBalance = value.cryptoBalance.toDomain(),
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
)
|
||||
null -> PaymentAccountStatusValue.Error.Unavailable
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import com.tangem.domain.pay.usecase.ReissueTangemPayCardUseCase
|
|||
import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase
|
||||
import com.tangem.domain.pay.usecase.StartTangemPayOrderPollingUseCase
|
||||
import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase
|
||||
import com.tangem.domain.pay.usecase.*
|
||||
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
|
||||
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
|
||||
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
|
||||
|
|
|
|||
|
|
@ -263,6 +263,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
val isDeactivated = productInstance?.status == CustomerInfo.ProductInstance.Status.DEACTIVATED
|
||||
val isFormer = state == CustomerInfo.State.FORMER
|
||||
val fiatBalance = fiatBalance
|
||||
val cryptoBalance = cryptoBalance
|
||||
|
||||
return when {
|
||||
kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty() -> {
|
||||
|
|
@ -272,10 +273,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
|||
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
|
||||
)
|
||||
}
|
||||
fiatBalance != null && (isDeactivated || isFormer) -> {
|
||||
fiatBalance != null && cryptoBalance != null && (isDeactivated || isFormer) -> {
|
||||
PaymentAccountStatusValue.Deactivated(
|
||||
source = StatusSource.ACTUAL,
|
||||
fiatBalance = fiatBalance,
|
||||
cryptoBalance = cryptoBalance,
|
||||
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
|
||||
)
|
||||
}
|
||||
cardInfo != null && productInstance != null && !customerId.isNullOrEmpty() -> convertToContentState(
|
||||
|
|
|
|||
|
|
@ -2,38 +2,32 @@ package com.tangem.data.pay.repository
|
|||
|
||||
import arrow.core.Either
|
||||
import arrow.core.flatMap
|
||||
import arrow.core.getOrElse
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.data.pay.store.PaymentAccountStatusesStore
|
||||
import com.tangem.data.pay.util.CustomerInfoConverter
|
||||
import com.tangem.datasource.api.pay.TangemPayApi
|
||||
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
|
||||
import com.tangem.datasource.api.pay.models.request.OrderRequest
|
||||
import com.tangem.datasource.api.pay.models.request.SetTangemPayEnabledRequest
|
||||
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
|
||||
import com.tangem.datasource.api.pay.models.response.FiatBalance
|
||||
import com.tangem.datasource.api.pay.models.response.OrderResponse
|
||||
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
|
||||
import com.tangem.datasource.local.visa.TangemPayStorage
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.CardDisplayName
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.kyc.KycStatus
|
||||
import com.tangem.domain.models.pay.TangemPayCardLimit
|
||||
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
|
||||
import com.tangem.domain.models.pay.TangemPayEligibilityType
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
|
||||
import com.tangem.domain.pay.model.CustomerInfo
|
||||
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
|
||||
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.domain.visa.model.TangemPayCardFrozenState
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -100,10 +94,10 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> {
|
||||
return requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.getCustomerMe(authHeader) }
|
||||
.flatMap { response ->
|
||||
val result = response.result
|
||||
val status = result?.productInstance?.status
|
||||
val result = response.result ?: return@flatMap VisaApiError.UnknownWithoutCode.left()
|
||||
val status = result.productInstance?.status
|
||||
val isDeactivated = status == CustomerMeResponse.ProductInstance.Status.DEACTIVATED
|
||||
val isFormer = result?.state?.let { CustomerInfo.State.fromString(it) } == CustomerInfo.State.FORMER
|
||||
val isFormer = result.state.let { CustomerInfo.State.fromString(it) } == CustomerInfo.State.FORMER
|
||||
if (isDeactivated || isFormer) {
|
||||
tangemPayStorage.storeIsTangemPayDeactivated(userWalletId)
|
||||
}
|
||||
|
|
@ -167,72 +161,16 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
@Suppress("ComplexCondition")
|
||||
private suspend fun getCustomerInfo(
|
||||
userWalletId: UserWalletId,
|
||||
response: CustomerMeResponse.Result?,
|
||||
response: CustomerMeResponse.Result,
|
||||
): CustomerInfo {
|
||||
val kycStatus = KycStatus.fromString(status = response?.kyc?.status)
|
||||
sendKycAnalytics(kycStatus)
|
||||
val customerInfo = CustomerInfoConverter.convert(response)
|
||||
sendKycAnalytics(customerInfo.kycStatus)
|
||||
|
||||
val card = response?.card
|
||||
val fiatBalance = response?.balance?.fiat
|
||||
val cryptoBalance = response?.balance?.crypto
|
||||
val availableForWithdrawal = response?.balance?.availableForWithdrawal
|
||||
val paymentAccount = response?.paymentAccount
|
||||
val cardInfo = if (paymentAccount != null && card != null && fiatBalance != null && cryptoBalance != null) {
|
||||
CardInfo(
|
||||
lastFourDigits = card.cardNumberEnd,
|
||||
balance = fiatBalance.availableBalance,
|
||||
currencyCode = fiatBalance.currency,
|
||||
depositAddress = response.depositAddress,
|
||||
isPinSet = response.card?.isPinSet == true,
|
||||
fiatBalance = fiatBalance.toDomain(),
|
||||
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
|
||||
id = cryptoBalance.id,
|
||||
chainId = cryptoBalance.chainId.toLong(),
|
||||
depositAddress = cryptoBalance.depositAddress.orEmpty(),
|
||||
tokenContractAddress = cryptoBalance.tokenContractAddress,
|
||||
balance = cryptoBalance.balance,
|
||||
),
|
||||
availableForWithdrawal = availableForWithdrawal?.amount.orZero(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
customerInfo.productInstance?.let { instance ->
|
||||
cardFrozenStateStore.store(key = instance.cardId, value = instance.frozenState)
|
||||
}
|
||||
val productInstance = response?.productInstance?.let { instance ->
|
||||
val cardFrozenState = when (instance.status) {
|
||||
CustomerMeResponse.ProductInstance.Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen
|
||||
else -> TangemPayCardFrozenState.Frozen
|
||||
}
|
||||
cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState)
|
||||
|
||||
val displayName = instance.displayName?.ifEmpty { null }
|
||||
|
||||
ProductInstance(
|
||||
id = instance.id,
|
||||
cardId = instance.cardId,
|
||||
frozenState = cardFrozenState,
|
||||
status = instance.status.toDomain(),
|
||||
displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null,
|
||||
actualCardLimit = instance.actualCardLimit?.parseCardLimit(),
|
||||
adminCardLimit = instance.adminCardLimit?.parseCardLimit(),
|
||||
)
|
||||
}
|
||||
return CustomerInfo(
|
||||
customerId = response?.id,
|
||||
productInstance = productInstance,
|
||||
kycStatus = kycStatus,
|
||||
cardInfo = cardInfo,
|
||||
state = response?.state?.let { CustomerInfo.State.fromString(it) } ?: CustomerInfo.State.UNDEFINED,
|
||||
fiatBalance = fiatBalance?.toDomain(),
|
||||
).also {
|
||||
lastFetchedCustomerInfoMap[userWalletId] = it
|
||||
}
|
||||
}
|
||||
|
||||
private fun CustomerMeResponse.CardLimit.parseCardLimit(): TangemPayCardLimit {
|
||||
return TangemPayCardLimit(
|
||||
amount = amount,
|
||||
period = TangemPayCardLimitPeriod.fromString(periodType),
|
||||
)
|
||||
return customerInfo.also { lastFetchedCustomerInfoMap[userWalletId] = it }
|
||||
}
|
||||
|
||||
private fun sendKycAnalytics(kycStatus: KycStatus) {
|
||||
|
|
@ -311,24 +249,4 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
setHideMainOnboardingBanner(userWalletId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun FiatBalance.toDomain() = PaymentAccountStatusValue.FiatBalance(
|
||||
availableBalance = availableBalance,
|
||||
currency = currency,
|
||||
)
|
||||
|
||||
private fun CustomerMeResponse.ProductInstance.Status.toDomain() = when (this) {
|
||||
CustomerMeResponse.ProductInstance.Status.NEW -> ProductInstance.Status.NEW
|
||||
CustomerMeResponse.ProductInstance.Status.READY_FOR_MANUFACTURING -> ProductInstance.Status.READY_FOR_MANUFACTURING
|
||||
CustomerMeResponse.ProductInstance.Status.MANUFACTURING -> ProductInstance.Status.MANUFACTURING
|
||||
CustomerMeResponse.ProductInstance.Status.SENT_TO_DELIVERY -> ProductInstance.Status.SENT_TO_DELIVERY
|
||||
CustomerMeResponse.ProductInstance.Status.DELIVERED -> ProductInstance.Status.DELIVERED
|
||||
CustomerMeResponse.ProductInstance.Status.ACTIVATING -> ProductInstance.Status.ACTIVATING
|
||||
CustomerMeResponse.ProductInstance.Status.ACTIVE -> ProductInstance.Status.ACTIVE
|
||||
CustomerMeResponse.ProductInstance.Status.BLOCKED -> ProductInstance.Status.BLOCKED
|
||||
CustomerMeResponse.ProductInstance.Status.DEACTIVATING -> ProductInstance.Status.DEACTIVATING
|
||||
CustomerMeResponse.ProductInstance.Status.DEACTIVATED -> ProductInstance.Status.DEACTIVATED
|
||||
CustomerMeResponse.ProductInstance.Status.CANCELED -> ProductInstance.Status.CANCELED
|
||||
CustomerMeResponse.ProductInstance.Status.UNKNOWN -> ProductInstance.Status.UNKNOWN
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import com.tangem.datasource.api.pay.models.response.WithdrawResponse
|
|||
import com.tangem.datasource.local.visa.TangemPayStorage
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
|
||||
import com.tangem.domain.pay.TangemPayWithdrawState
|
||||
import com.tangem.domain.pay.WithdrawalResult
|
||||
|
|
@ -222,13 +223,13 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun hasWithdrawOrder(userWallet: UserWallet): Boolean {
|
||||
val orderId = tangemPayStorage.getActiveWithdrawOrderId(userWallet.walletId)
|
||||
override suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean {
|
||||
val orderId = tangemPayStorage.getActiveWithdrawOrderId(userWalletId)
|
||||
if (orderId.isNullOrEmpty()) return false
|
||||
val orderData = orderRepository.getOrderData(userWalletId = userWallet.walletId, orderId = orderId).getOrNull()
|
||||
val orderData = orderRepository.getOrderData(userWalletId = userWalletId, orderId = orderId).getOrNull()
|
||||
val isActive = orderData?.status == OrderStatus.NEW || orderData?.status == OrderStatus.PROCESSING
|
||||
if (!isActive) {
|
||||
tangemPayStorage.deleteActiveWithdrawOrder(userWalletId = userWallet.walletId)
|
||||
tangemPayStorage.deleteActiveWithdrawOrder(userWalletId = userWalletId)
|
||||
}
|
||||
return isActive
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
package com.tangem.data.pay.util
|
||||
|
||||
import arrow.core.getOrElse
|
||||
import com.tangem.datasource.api.pay.models.response.CryptoBalance
|
||||
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
|
||||
import com.tangem.datasource.api.pay.models.response.FiatBalance
|
||||
import com.tangem.domain.models.account.CardDisplayName
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.kyc.KycStatus
|
||||
import com.tangem.domain.models.pay.TangemPayCardLimit
|
||||
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
|
||||
import com.tangem.domain.pay.model.CustomerInfo
|
||||
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
|
||||
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
|
||||
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.Status
|
||||
import com.tangem.domain.visa.model.TangemPayCardFrozenState
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, CustomerInfo> {
|
||||
@Suppress("ComplexCondition")
|
||||
override fun convert(value: CustomerMeResponse.Result): CustomerInfo {
|
||||
val kycStatus = KycStatus.fromString(status = value.kyc?.status)
|
||||
val card = value.card
|
||||
val fiatBalance = value.balance?.fiat
|
||||
val cryptoBalance = value.balance?.crypto
|
||||
val paymentAccount = value.paymentAccount
|
||||
val cardInfo = if (paymentAccount != null && card != null && fiatBalance != null && cryptoBalance != null) {
|
||||
CardInfo(
|
||||
lastFourDigits = card.cardNumberEnd,
|
||||
balance = fiatBalance.availableBalance,
|
||||
currencyCode = fiatBalance.currency,
|
||||
depositAddress = value.depositAddress,
|
||||
isPinSet = value.card?.isPinSet == true,
|
||||
fiatBalance = fiatBalance.toDomain(),
|
||||
cryptoBalance = cryptoBalance.toDomain(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val productInstance = value.productInstance?.let { instance ->
|
||||
val status = instance.status.toDomain()
|
||||
val cardFrozenState = when (status) {
|
||||
Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen
|
||||
else -> TangemPayCardFrozenState.Frozen
|
||||
}
|
||||
val displayName = instance.displayName?.ifEmpty { null }
|
||||
|
||||
ProductInstance(
|
||||
id = instance.id,
|
||||
cardId = instance.cardId,
|
||||
frozenState = cardFrozenState,
|
||||
status = status,
|
||||
displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null,
|
||||
actualCardLimit = instance.actualCardLimit?.parseCardLimit(),
|
||||
adminCardLimit = instance.adminCardLimit?.parseCardLimit(),
|
||||
)
|
||||
}
|
||||
return CustomerInfo(
|
||||
customerId = value.id,
|
||||
productInstance = productInstance,
|
||||
kycStatus = kycStatus,
|
||||
cardInfo = cardInfo,
|
||||
state = CustomerInfo.State.fromString(value.state),
|
||||
fiatBalance = fiatBalance?.toDomain(),
|
||||
cryptoBalance = cryptoBalance?.toDomain(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun CustomerMeResponse.CardLimit.parseCardLimit(): TangemPayCardLimit {
|
||||
return TangemPayCardLimit(
|
||||
amount = amount,
|
||||
period = TangemPayCardLimitPeriod.fromString(periodType),
|
||||
)
|
||||
}
|
||||
|
||||
private fun FiatBalance.toDomain() = PaymentAccountStatusValue.FiatBalance(
|
||||
availableBalance = availableBalance,
|
||||
currency = currency,
|
||||
)
|
||||
|
||||
private fun CryptoBalance.toDomain() = PaymentAccountStatusValue.CryptoBalance(
|
||||
id = id,
|
||||
chainId = chainId.toLong(),
|
||||
depositAddress = depositAddress.orEmpty(),
|
||||
tokenContractAddress = tokenContractAddress,
|
||||
balance = balance,
|
||||
)
|
||||
|
||||
private fun CustomerMeResponse.ProductInstance.Status.toDomain(): Status = when (this) {
|
||||
CustomerMeResponse.ProductInstance.Status.NEW -> Status.NEW
|
||||
CustomerMeResponse.ProductInstance.Status.READY_FOR_MANUFACTURING -> Status.READY_FOR_MANUFACTURING
|
||||
CustomerMeResponse.ProductInstance.Status.MANUFACTURING -> Status.MANUFACTURING
|
||||
CustomerMeResponse.ProductInstance.Status.SENT_TO_DELIVERY -> Status.SENT_TO_DELIVERY
|
||||
CustomerMeResponse.ProductInstance.Status.DELIVERED -> Status.DELIVERED
|
||||
CustomerMeResponse.ProductInstance.Status.ACTIVATING -> Status.ACTIVATING
|
||||
CustomerMeResponse.ProductInstance.Status.ACTIVE -> Status.ACTIVE
|
||||
CustomerMeResponse.ProductInstance.Status.BLOCKED -> Status.BLOCKED
|
||||
CustomerMeResponse.ProductInstance.Status.DEACTIVATING -> Status.DEACTIVATING
|
||||
CustomerMeResponse.ProductInstance.Status.DEACTIVATED -> Status.DEACTIVATED
|
||||
CustomerMeResponse.ProductInstance.Status.CANCELED -> Status.CANCELED
|
||||
CustomerMeResponse.ProductInstance.Status.UNKNOWN -> Status.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@ import com.tangem.data.pay.entity.TangemPayCurrencyFactory
|
|||
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
|
|
@ -17,9 +19,30 @@ internal class PaymentAccountStatusValueDMConverterTest {
|
|||
|
||||
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory = mockk()
|
||||
private val userWalletId = UserWalletId("1234567890ABCDEF")
|
||||
private val cryptoCurrency: CryptoCurrency.Token = mockk()
|
||||
|
||||
init {
|
||||
every { tangemPayCurrencyFactory.create(userWalletId) } returns cryptoCurrency
|
||||
}
|
||||
|
||||
private val converter = PaymentAccountStatusValueDMConverter(tangemPayCurrencyFactory)
|
||||
|
||||
private fun cryptoBalance() = PaymentAccountStatusValue.CryptoBalance(
|
||||
id = "usd-coin",
|
||||
chainId = 137,
|
||||
depositAddress = "0xDEPOSIT",
|
||||
tokenContractAddress = "0xCONTRACT",
|
||||
balance = BigDecimal("10"),
|
||||
)
|
||||
|
||||
private fun cryptoBalanceDM() = PaymentAccountStatusValueDM.CryptoBalanceDM(
|
||||
id = "usd-coin",
|
||||
chainId = 137,
|
||||
depositAddress = "0xDEPOSIT",
|
||||
tokenContractAddress = "0xCONTRACT",
|
||||
balance = BigDecimal("10"),
|
||||
)
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class Convert {
|
||||
|
|
@ -44,7 +67,9 @@ internal class PaymentAccountStatusValueDMConverterTest {
|
|||
fiatBalance = PaymentAccountStatusValue.FiatBalance(
|
||||
availableBalance = BigDecimal("100"),
|
||||
currency = "USD",
|
||||
)
|
||||
),
|
||||
cryptoBalance = cryptoBalance(),
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
)
|
||||
|
||||
// WHEN
|
||||
|
|
@ -117,7 +142,8 @@ internal class PaymentAccountStatusValueDMConverterTest {
|
|||
fiatBalance = PaymentAccountStatusValueDM.FiatBalanceDM(
|
||||
availableBalance = BigDecimal("200"),
|
||||
currency = "EUR",
|
||||
)
|
||||
),
|
||||
cryptoBalance = cryptoBalanceDM(),
|
||||
)
|
||||
|
||||
// WHEN
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ internal data class WcSolanaSignTransactionRequest(
|
|||
val feePayer: String?,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
internal data class WcSolanaSignAndSendTransactionRequest(
|
||||
@Json(name = "transaction")
|
||||
val transaction: String,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
internal data class WcSolanaSignAllTransactionRequest(
|
||||
@Json(name = "transactions")
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.tangem.data.walletconnect.utils.WcNamespaceConverter
|
|||
import com.tangem.data.walletconnect.utils.WcNetworksConverter
|
||||
import com.tangem.domain.walletconnect.model.HandleMethodError
|
||||
import com.tangem.domain.walletconnect.model.WcSolanaMethod
|
||||
import com.tangem.domain.walletconnect.model.WcSolanaMethod.*
|
||||
import com.tangem.domain.walletconnect.model.WcSolanaMethodName
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
|
|
@ -55,9 +56,11 @@ internal class WcSolanaNetwork(
|
|||
.orEmpty()
|
||||
|
||||
val accountAddress = when (method) {
|
||||
is WcSolanaMethod.SignAllTransaction -> anyAddress()
|
||||
is WcSolanaMethod.SignMessage -> anyAddress()
|
||||
is WcSolanaMethod.SignTransaction -> method.address ?: anyAddress()
|
||||
is SignAllTransaction,
|
||||
is SignMessage,
|
||||
is SignAndSendTransaction,
|
||||
-> anyAddress()
|
||||
is SignTransaction -> method.address ?: anyAddress()
|
||||
}
|
||||
val walletNetwork = networksConverter
|
||||
.findWalletNetworkForRequest(request, session, accountAddress)
|
||||
|
|
@ -73,9 +76,10 @@ internal class WcSolanaNetwork(
|
|||
networkDerivationsCount = networkDerivationsCount,
|
||||
)
|
||||
return when (method) {
|
||||
is WcSolanaMethod.SignMessage -> factories.messageSign.create(context, method)
|
||||
is WcSolanaMethod.SignTransaction -> factories.signTransaction.create(context, method)
|
||||
is WcSolanaMethod.SignAllTransaction -> factories.signAllTransaction.create(context, method)
|
||||
is SignMessage -> factories.messageSign.create(context, method)
|
||||
is SignTransaction -> factories.signTransaction.create(context, method)
|
||||
is SignAllTransaction -> factories.signAllTransaction.create(context, method)
|
||||
is SignAndSendTransaction -> factories.signAndSendTransaction.create(context, method)
|
||||
}.right()
|
||||
}
|
||||
|
||||
|
|
@ -103,7 +107,7 @@ internal class WcSolanaNetwork(
|
|||
.getOrElse { return it.left() }
|
||||
?.let { request ->
|
||||
val humanMsg = request.message.decodeBase58()?.toHexString().orEmpty()
|
||||
WcSolanaMethod.SignMessage(
|
||||
SignMessage(
|
||||
pubKey = request.publicKey,
|
||||
rawMessage = request.message,
|
||||
humanMsg = humanMsg,
|
||||
|
|
@ -111,10 +115,15 @@ internal class WcSolanaNetwork(
|
|||
}
|
||||
WcSolanaMethodName.SignTransaction -> moshi.fromJson<WcSolanaSignTransactionRequest>(rawParams)
|
||||
.getOrElse { return it.left() }
|
||||
?.let { request -> WcSolanaMethod.SignTransaction(request.transaction, request.feePayer) }
|
||||
?.let { request -> SignTransaction(request.transaction, request.feePayer) }
|
||||
WcSolanaMethodName.SendAllTransaction -> moshi.fromJson<WcSolanaSignAllTransactionRequest>(rawParams)
|
||||
.getOrElse { return it.left() }
|
||||
?.let { request -> WcSolanaMethod.SignAllTransaction(request.transactions) }
|
||||
?.let { request -> SignAllTransaction(request.transactions) }
|
||||
WcSolanaMethodName.SignAndSendTransaction -> moshi.fromJson<WcSolanaSignAndSendTransactionRequest>(
|
||||
rawParams,
|
||||
)
|
||||
.getOrElse { return it.left() }
|
||||
?.let { request -> SignAndSendTransaction(request.transaction) }
|
||||
}.right()
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +131,7 @@ internal class WcSolanaNetwork(
|
|||
val messageSign: WcSolanaMessageSignUseCase.Factory,
|
||||
val signTransaction: WcSolanaSignTransactionUseCase.Factory,
|
||||
val signAllTransaction: WcSolanaSignAllTransactionUseCase.Factory,
|
||||
val signAndSendTransaction: WcSolanaSignAndSendTransactionUseCase.Factory,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
package com.tangem.data.walletconnect.network.solana
|
||||
|
||||
import arrow.core.left
|
||||
import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.extensions.decodeBase58
|
||||
import com.tangem.blockchain.extensions.encodeBase58
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
|
||||
import com.tangem.data.walletconnect.sign.SignCollector
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
|
||||
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
|
||||
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.transaction.usecase.SendLargeSolanaTransactionUseCase
|
||||
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
|
||||
import com.tangem.domain.walletconnect.WcAnalyticEvents.SolanaLargeTransactionStatus
|
||||
import com.tangem.domain.walletconnect.error.parseSendError
|
||||
import com.tangem.domain.walletconnect.model.WcSolanaMethod
|
||||
import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck
|
||||
import com.tangem.domain.walletconnect.usecase.method.SignRequirements
|
||||
import com.tangem.domain.walletconnect.usecase.method.WcSignState
|
||||
import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase
|
||||
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class WcSolanaSignAndSendTransactionUseCase @AssistedInject constructor(
|
||||
override val respondService: WcRespondService,
|
||||
override val analytics: AnalyticsEventHandler,
|
||||
private val sendTransaction: SendTransactionUseCase,
|
||||
private val sendLargeSolanaTransactionUseCase: SendLargeSolanaTransactionUseCase,
|
||||
@Assisted override val context: WcMethodUseCaseContext,
|
||||
@Assisted override val method: WcSolanaMethod.SignAndSendTransaction,
|
||||
blockAidDelegate: BlockAidVerificationDelegate,
|
||||
addressConverter: SolanaBlockAidAddressConverter,
|
||||
) : BaseWcSignUseCase<Nothing, TransactionData>(),
|
||||
WcTransactionUseCase,
|
||||
SignRequirements {
|
||||
|
||||
override val securityStatus = blockAidDelegate.getSecurityStatus(
|
||||
network = network,
|
||||
method = method,
|
||||
rawSdkRequest = rawSdkRequest,
|
||||
session = session,
|
||||
accountAddress = addressConverter.convert(context.accountAddress),
|
||||
).map { lce -> lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) } }
|
||||
|
||||
override suspend fun SignCollector<TransactionData>.onSign(state: WcSignState<TransactionData>) {
|
||||
val hash = state.signModel.getTxHashFromCompiled()
|
||||
val formattedHash = getFormattedHash(hash) // uses for flow sendLargeSolanaTransaction
|
||||
if (context.session.wallet is UserWallet.Cold && isLargeHash(formattedHash)) {
|
||||
// workaround for large transactions that cannot be signed directly by card
|
||||
TangemLogger.w("The transaction hash is too large to be signed directly: ${formattedHash.size} bytes")
|
||||
sendLargeSolanaTransactionUseCase(context.session.wallet as UserWallet.Cold, context.network, formattedHash)
|
||||
.fold(
|
||||
ifLeft = { error ->
|
||||
analytics.send(SolanaLargeTransactionStatus(SolanaLargeTransactionStatus.Status.Failed))
|
||||
TangemLogger.e(error.toString())
|
||||
emit(state.toResult(parseSendError(error).left()))
|
||||
},
|
||||
ifRight = {
|
||||
analytics.send(SolanaLargeTransactionStatus(SolanaLargeTransactionStatus.Status.Success))
|
||||
val emptyRespond = ByteArray(0).formatAsSolanaSignature()
|
||||
val respondResult = respondService.respond(rawSdkRequest, emptyRespond)
|
||||
emit(state.toResult(respondResult))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
val signedHash =
|
||||
sendTransaction.invoke(txData = state.signModel, userWallet = wallet, network = network)
|
||||
.onLeft { error ->
|
||||
emit(state.toResult(parseSendError(error).left()))
|
||||
}
|
||||
.getOrNull()
|
||||
?: return
|
||||
val respond = signedHash.formatAsSolanaSignature()
|
||||
val respondResult = respondService.respond(rawSdkRequest, respond)
|
||||
emit(state.toResult(respondResult))
|
||||
}
|
||||
}
|
||||
|
||||
override fun invoke(): Flow<WcSignState<TransactionData>> {
|
||||
val data = method.transaction.decodeBase58() ?: byteArrayOf()
|
||||
|
||||
val transactionData = TransactionData.Compiled(
|
||||
value = TransactionData.Compiled.Data.Bytes(data),
|
||||
)
|
||||
return delegate.invoke(transactionData)
|
||||
}
|
||||
|
||||
private fun String.formatAsSolanaSignature(): String {
|
||||
return "{ signature: \"${this}\" }"
|
||||
}
|
||||
|
||||
private fun ByteArray.formatAsSolanaSignature(): String {
|
||||
return "{ signature: \"${this.encodeBase58()}\" }"
|
||||
}
|
||||
|
||||
private fun TransactionData.getTxHashFromCompiled(): ByteArray {
|
||||
return when (this) {
|
||||
is TransactionData.Compiled -> (value as? TransactionData.Compiled.Data.Bytes)?.data
|
||||
?: error("Invalid transaction data")
|
||||
is TransactionData.Uncompiled -> error("Transaction must be compiled")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLargeHash(hash: ByteArray): Boolean {
|
||||
return hash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
|
||||
}
|
||||
|
||||
private fun getFormattedHash(hash: ByteArray): ByteArray {
|
||||
return try {
|
||||
SolanaTransactionHelper.removeSignaturesPlaceholders(hash)
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Failed to format the hash: ${e.message}")
|
||||
hash
|
||||
}
|
||||
}
|
||||
|
||||
override fun isMultipleSignRequired(): Boolean {
|
||||
val data = method.transaction.decodeBase58() ?: byteArrayOf()
|
||||
return isLargeHash(data)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
context: WcMethodUseCaseContext,
|
||||
method: WcSolanaMethod.SignAndSendTransaction,
|
||||
): WcSolanaSignAndSendTransactionUseCase
|
||||
}
|
||||
}
|
||||
|
|
@ -200,20 +200,33 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
|
|||
verifyContext: Wallet.Model.VerifyContext,
|
||||
): Either<WcPairError, WcPairState.Proposal> = runCatching {
|
||||
val proposalAccountNetwork = associateNetworksDelegate.associateAccounts(sessionProposal)
|
||||
// Display URL: shown to the user and logged to analytics. Reown's verified origin when
|
||||
// present, otherwise its `verify.walletconnect.org` fallback. NOT trustworthy for
|
||||
// security checks: when validation is INVALID, getDappOriginUrl returns the dApp-claimed
|
||||
// origin (so the UI can show what was claimed), which a scam dApp can spoof.
|
||||
val displayUrl = verifyContext.getDappOriginUrl()
|
||||
val verificationInfo = when {
|
||||
verifyContext.validation == Wallet.Model.Validation.INVALID -> CheckDAppResult.UNSAFE
|
||||
verifyContext.isScam == true -> CheckDAppResult.UNSAFE
|
||||
else -> blockAidVerifier.verifyDApp(DAppData(sessionProposal.url)).getOrElse { error ->
|
||||
TangemLogger.withTag(WC_TAG).e("Failed to verify DApp ${sessionProposal.name}", error)
|
||||
CheckDAppResult.FAILED_TO_VERIFY
|
||||
// BlockAid is scanned only against the Reown-verified origin (validation == VALID
|
||||
// guarantees Reown confirmed origin matches the dApp's registered domain).
|
||||
// For UNKNOWN we have no trustworthy URL: passing a dApp-claimed URL would let an
|
||||
// impersonator (e.g. a scam claiming metadata.url=dydx.trade) inherit its target's
|
||||
// BlockAid verdict.
|
||||
verifyContext.validation == Wallet.Model.Validation.VALID -> {
|
||||
blockAidVerifier.verifyDApp(DAppData(verifyContext.origin)).getOrElse { error ->
|
||||
TangemLogger.withTag(WC_TAG).e("Failed to verify DApp ${sessionProposal.name}", error)
|
||||
CheckDAppResult.FAILED_TO_VERIFY
|
||||
}
|
||||
}
|
||||
else -> CheckDAppResult.FAILED_TO_VERIFY
|
||||
}
|
||||
val requestedNetworks = proposalAccountNetwork
|
||||
.values.map { it.available.plus(it.required) }.flatten().toSet()
|
||||
analytics.send(
|
||||
WcAnalyticEvents.PairRequested(
|
||||
dAppName = sessionProposal.name,
|
||||
dAppUrl = sessionProposal.url,
|
||||
dAppUrl = displayUrl,
|
||||
network = requestedNetworks,
|
||||
domainVerification = verificationInfo,
|
||||
),
|
||||
|
|
@ -221,7 +234,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
|
|||
val appMetaData = WcAppMetaData(
|
||||
name = sessionProposal.name,
|
||||
description = sessionProposal.description,
|
||||
url = sessionProposal.url,
|
||||
url = displayUrl,
|
||||
icons = sessionProposal.icons.map { it.toString() },
|
||||
redirect = sessionProposal.redirect,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import com.reown.walletkit.client.Wallet
|
|||
import com.reown.walletkit.client.WalletKit
|
||||
import com.tangem.domain.walletconnect.WC_TAG
|
||||
import com.tangem.data.walletconnect.utils.WcSdkObserver
|
||||
import com.tangem.data.walletconnect.utils.getDappOriginUrl
|
||||
import com.tangem.datasource.local.walletconnect.WalletConnectStore
|
||||
import com.tangem.domain.walletconnect.model.WcPairError
|
||||
import com.tangem.domain.walletconnect.model.WcPairError.ApprovalFailed
|
||||
|
|
@ -110,9 +109,10 @@ internal class WcPairSdkDelegate(
|
|||
sessionProposal: Wallet.Model.SessionProposal,
|
||||
verifyContext: Wallet.Model.VerifyContext,
|
||||
) {
|
||||
val sessionProposalWithRealUrl = sessionProposal.copy(url = verifyContext.getDappOriginUrl())
|
||||
// Triggered when wallet receives the session proposal sent by a Dapp
|
||||
onSessionProposal.trySend(sessionProposalWithRealUrl to verifyContext)
|
||||
// Triggered when wallet receives the session proposal sent by a Dapp.
|
||||
// Pass the proposal through unchanged so consumers can decide between the dApp-claimed
|
||||
// metadata url (sessionProposal.url) and the Verify-API origin (verifyContext.getDappOriginUrl()).
|
||||
onSessionProposal.trySend(sessionProposal to verifyContext)
|
||||
}
|
||||
|
||||
override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ internal class BlockAidVerificationDelegate @Inject constructor(
|
|||
is WcEthMethod -> TransactionParams.Evm(rawSdkRequest.request.params)
|
||||
is WcSolanaMethod.SignAllTransaction -> TransactionParams.Solana(method.transaction)
|
||||
is WcSolanaMethod.SignTransaction -> TransactionParams.Solana(listOf(method.transaction))
|
||||
is WcSolanaMethod.SignAndSendTransaction -> TransactionParams.Solana(listOf(method.transaction))
|
||||
is WcSolanaMethod.SignMessage,
|
||||
is WcBitcoinMethod,
|
||||
-> {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,25 @@ internal class DefaultWcPairUseCaseTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifyDApp uses verifyContext origin when sessionProposal url is spoofed`() = runTest {
|
||||
val spoofedProposal = sdkProposal.copy(url = "https://evil-spoofed.example/")
|
||||
coEvery { sdkDelegate.pair(url) } returns (spoofedProposal to sdkVerifyContext).right()
|
||||
coEvery { associateNetworksDelegate.associateAccounts(spoofedProposal) } returns mapOf()
|
||||
coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE }
|
||||
|
||||
val useCase = useCaseFactory()
|
||||
useCase.invoke().test {
|
||||
assertEquals(loading, awaitItem())
|
||||
coVerifyOrder {
|
||||
sdkDelegate.pair(url)
|
||||
blockAidVerifier.verifyDApp(DAppData(sdkVerifyContext.origin))
|
||||
}
|
||||
assert(awaitItem() is WcPairState.Proposal)
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success pair and approve flow`() = runTest {
|
||||
val approveLoading = WcPairState.Approving.Loading(sessionForApprove)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue