Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-19 22:50:39 +07:00
commit f5eaca92f6
64 changed files with 1271 additions and 301 deletions

@ -1 +1 @@
Subproject commit a5d1a89425a95bc9c90c7a6fed3c578b0d324994
Subproject commit 2d18650f6d4286353046ccb841745cef107c4fa6

View file

@ -2,6 +2,9 @@ package com.tangem.tap.data
import android.content.Context
import com.squareup.moshi.Moshi
import com.tangem.data.pay.entity.WithdrawStoreData
import com.tangem.data.pay.util.WithdrawStateConverter
import com.tangem.data.pay.util.WithdrawStoreDataConverter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
@ -11,6 +14,7 @@ import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayWithdrawState
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.sdk.storage.AndroidSecureStorageV2
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -41,6 +45,8 @@ internal class DefaultTangemPayStorage @Inject constructor(
}
private val tokensAdapter by lazy { moshi.adapter(TangemPayAuthTokens::class.java) }
private val withdrawStoreDataConverter by lazy { WithdrawStoreDataConverter() }
private val withdrawStateConverter by lazy { WithdrawStateConverter() }
override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) {
withContext(dispatcherProvider.io) {
@ -131,10 +137,12 @@ internal class DefaultTangemPayStorage @Inject constructor(
return appPreferencesStore.getSyncOrNull(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId))
}
override suspend fun storeWithdrawOrder(userWalletId: UserWalletId, orderId: String) {
override suspend fun storeWithdrawOrder(userWalletId: UserWalletId, data: TangemPayWithdrawState) {
appPreferencesStore.editData { mutablePreferences ->
val orders = mutablePreferences.getObjectMap<String>(PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY)
.plus(createWithdrawOrderIdKey(userWalletId) to orderId)
val orders = mutablePreferences.getObjectMap<TangemPayWithdrawState>(
PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY,
)
.plus(createWithdrawOrderIdKey(userWalletId) to withdrawStoreDataConverter.convert(data))
mutablePreferences.setObjectMap(
key = PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY,
value = orders,
@ -142,14 +150,19 @@ internal class DefaultTangemPayStorage @Inject constructor(
}
}
override suspend fun getWithdrawOrderId(userWalletId: UserWalletId): String? {
val orders = appPreferencesStore.getObjectMapSync<String>(PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY)
return orders[createWithdrawOrderIdKey(userWalletId)]
override suspend fun getWithdrawOrder(userWalletId: UserWalletId): TangemPayWithdrawState? {
val orders = appPreferencesStore.getObjectMapSync<WithdrawStoreData>(
PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY,
)
val data = orders[createWithdrawOrderIdKey(userWalletId)] ?: return null
return withdrawStateConverter.convert(data)
}
override suspend fun deleteWithdrawOrder(userWalletId: UserWalletId) {
appPreferencesStore.editData { mutablePreferences ->
val orders = mutablePreferences.getObjectMap<String>(PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY)
val orders = mutablePreferences.getObjectMap<WithdrawStoreData>(
PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY,
)
.minus(createWithdrawOrderIdKey(userWalletId))
mutablePreferences.setObjectMap(
key = PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY,

View file

@ -1,9 +1,12 @@
package com.tangem.tap.di.domain
import com.tangem.domain.hotwallet.CheckHotWalletUpgradeBannerUseCase
import com.tangem.domain.hotwallet.CloseHotWalletUpgradeBannerUseCase
import com.tangem.domain.hotwallet.GetAccessCodeSkippedUseCase
import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
import com.tangem.domain.hotwallet.IsAccessCodeSimpleUseCase
import com.tangem.domain.hotwallet.SetAccessCodeSkippedUseCase
import com.tangem.domain.hotwallet.ShouldShowUpgradeHotWalletBannerUseCase
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import dagger.Module
import dagger.Provides
@ -40,4 +43,28 @@ internal object HotWalletDomainModule {
): IsHotWalletCreationSupported {
return IsHotWalletCreationSupported(hotWalletRepository)
}
@Provides
@Singleton
fun provideCheckHotWalletUpgradeBannerUseCase(
hotWalletRepository: HotWalletRepository,
): CheckHotWalletUpgradeBannerUseCase {
return CheckHotWalletUpgradeBannerUseCase(hotWalletRepository)
}
@Provides
@Singleton
fun provideCloseHotWalletUpgradeBannerUseCase(
hotWalletRepository: HotWalletRepository,
): CloseHotWalletUpgradeBannerUseCase {
return CloseHotWalletUpgradeBannerUseCase(hotWalletRepository)
}
@Provides
@Singleton
fun provideShouldShowUpgradeHotWalletBannerUseCase(
hotWalletRepository: HotWalletRepository,
): ShouldShowUpgradeHotWalletBannerUseCase {
return ShouldShowUpgradeHotWalletBannerUseCase(hotWalletRepository)
}
}

View file

@ -123,7 +123,13 @@ internal class DefaultTangemSdkManager(
}
if (awaitInitialization) {
awaitAuthenticationManagerInitialization().needEnrollBiometrics
val manager = awaitAuthenticationManagerInitialization()
if (manager.isInitialized) {
manager.needEnrollBiometrics
} else {
false
}
} else {
throw e
}
@ -142,7 +148,11 @@ internal class DefaultTangemSdkManager(
if (awaitInitialization) {
val manager = awaitAuthenticationManagerInitialization()
manager.canAuthenticate || manager.needEnrollBiometrics
if (manager.isInitialized) {
manager.canAuthenticate || manager.needEnrollBiometrics
} else {
false
}
} else {
throw e
}

View file

@ -42,7 +42,7 @@
},
{
"name": "SWAP_MARKET_LIST_ENABLED",
"version": "undefined"
"version": "5.34"
},
{
"name": "EARN_BLOCK_ENABLED",
@ -54,6 +54,6 @@
},
{
"name": "WALLET_REORDER_FEATURE_ENABLED",
"version": "undefined"
"version": "5.34"
}
]

View file

@ -27,6 +27,7 @@ data class OrderResponse(
@Json(name = "emboss_name") val embossName: String?,
@Json(name = "product_instance_id") val productInstanceId: String?,
@Json(name = "payment_account_id") val paymentAccountId: String?,
@Json(name = "transaction_hash") val transactionHash: String?,
)
}
}

View file

@ -134,10 +134,6 @@ object PreferencesKeys {
val SHOULD_SHOW_UPGRADE_BANNER_KEY by lazy { stringPreferencesKey(name = "shouldShowUpgradeBanner") }
val SHOULD_SHOW_NEXT_TIME_UPGRADE_BANNER_KEY by lazy {
stringPreferencesKey(name = "shouldShowNextTimeUpgradeBanner")
}
val UPGRADE_BANNER_CLOSURE_TIMESTAMP_KEY by lazy { stringPreferencesKey(name = "upgradeBannerClosureTimestamp") }
val WALLET_CREATION_TIMESTAMP_KEY by lazy { stringPreferencesKey(name = "walletCreationTimestamp") }

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayWithdrawState
import com.tangem.domain.visa.model.TangemPayAuthTokens
@Suppress("TooManyFunctions")
@ -28,9 +29,9 @@ interface TangemPayStorage {
suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean)
suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean?
suspend fun storeWithdrawOrder(userWalletId: UserWalletId, orderId: String)
suspend fun storeWithdrawOrder(userWalletId: UserWalletId, data: TangemPayWithdrawState)
suspend fun getWithdrawOrderId(userWalletId: UserWalletId): String?
suspend fun getWithdrawOrder(userWalletId: UserWalletId): TangemPayWithdrawState?
suspend fun deleteWithdrawOrder(userWalletId: UserWalletId)

View file

@ -103,9 +103,9 @@ object DateTimeFormatters {
*/
val localFullDate: DateTimeFormatter by lazy {
val locale = Locale.getDefault()
val datePattern = DateFormat.getBestDateTimePattern(locale, "d MMMM")
val datePattern = icuPatternToJodaPattern(DateFormat.getBestDateTimePattern(locale, "d MMMM"))
val timeSkeleton = if (is12HourFormat) "h:mm a" else "HH:mm"
val timePattern = DateFormat.getBestDateTimePattern(locale, timeSkeleton)
val timePattern = icuPatternToJodaPattern(DateFormat.getBestDateTimePattern(locale, timeSkeleton))
val fullPattern = "$datePattern, $timePattern"
DateTimeFormatterBuilder()
.appendPattern(fullPattern)
@ -128,13 +128,26 @@ object DateTimeFormatters {
*/
fun getBestFormatterBySkeleton(skeleton: String): DateTimeFormatter {
val skeletonWithLocale = skeleton.replaceHourLetters()
val icuPattern = DateFormat.getBestDateTimePattern(Locale.getDefault(), skeletonWithLocale)
val jodaPattern = icuPatternToJodaPattern(icuPattern)
return DateTimeFormatterBuilder()
.appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), skeletonWithLocale))
.appendPattern(jodaPattern)
.toFormatter()
.withLocale(Locale.getDefault())
}
/**
* Converts ICU date/time pattern (from [DateFormat.getBestDateTimePattern]) to Joda-Time compatible pattern.
*/
internal fun icuPatternToJodaPattern(icuPattern: String): String {
return icuPattern
.replace("LLLL", "MMMM")
.replace("LLL", "MMM")
.replace("LL", "MM")
.replace("L", "M")
}
private fun String.replaceHourLetters(): String {
return if (is12HourFormat) {
this.replace('H', 'h').replace('k', 'K')

View file

@ -0,0 +1,143 @@
package com.tangem.core.ui.utils
import com.google.common.truth.Truth
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Unit tests for [DateTimeFormatters], in particular for conversion of ICU date/time patterns
* to Joda-Time compatible patterns.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DateTimeFormattersTest {
@Test
fun `converts LLLL to MMMM - full standalone month pattern that crashes on Chinese locale`() {
// Arrange
val icuPattern = "d LLLL"
// Act
val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern)
// Assert
Truth.assertThat(actual).isEqualTo("d MMMM")
}
@Test
fun `converts LLL to MMM - short standalone month`() {
// Arrange
val icuPattern = "dd LLL yyyy"
// Act
val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern)
// Assert
Truth.assertThat(actual).isEqualTo("dd MMM yyyy")
}
@Test
fun `converts LL to MM - numeric standalone month`() {
// Arrange
val icuPattern = "yyyy-MM-LL"
// Act
val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern)
// Assert
Truth.assertThat(actual).isEqualTo("yyyy-MM-MM")
}
@Test
fun `converts single L to M`() {
// Arrange
val icuPattern = "d/L/yyyy"
// Act
val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern)
// Assert
Truth.assertThat(actual).isEqualTo("d/M/yyyy")
}
@Test
fun `leaves pattern without L unchanged`() {
// Arrange
val icuPattern = "dd.MM.yyyy HH:mm"
// Act
val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern)
// Assert
Truth.assertThat(actual).isEqualTo("dd.MM.yyyy HH:mm")
}
@Test
fun `leaves pattern with M unchanged`() {
// Arrange
val icuPattern = "d MMMM yyyy"
// Act
val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern)
// Assert
Truth.assertThat(actual).isEqualTo("d MMMM yyyy")
}
@Test
fun `handles mixed ICU pattern as returned for Chinese locale - d MMMM`() {
// Arrange
val icuPatternWithStandaloneMonth = "d LLLL"
// Act
val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPatternWithStandaloneMonth)
// Assert — Joda-Time can parse and format this without IllegalArgumentException
Truth.assertThat(actual).isEqualTo("d MMMM")
}
@Test
fun `handles empty string`() {
// Arrange
val icuPattern = ""
// Act
val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `handles pattern with only literal characters`() {
// Arrange
val icuPattern = " 'at' "
// Act
val actual = DateTimeFormatters.icuPatternToJodaPattern(icuPattern)
// Assert
Truth.assertThat(actual).isEqualTo(" 'at' ")
}
@Test
fun `getBestFormatterBySkeleton with d MMMM skeleton produces formatter that does not throw on format`() {
// Arrange
val formatter = DateTimeFormatters.getBestFormatterBySkeleton("d MMMM")
val date = org.joda.time.DateTime(2025, 2, 13, 12, 0, 0, 0)
// Act & Assert
val formatted = formatter.print(date)
Truth.assertThat(formatted).isNotEmpty()
}
@Test
fun `getBestFormatterBySkeleton with dd MMMM skeleton produces formatter that does not throw on format`() {
// Arrange
val formatter = DateTimeFormatters.getBestFormatterBySkeleton("dd MMMM")
val date = org.joda.time.DateTime(2025, 2, 13, 12, 0, 0, 0)
// Act & Assert
val formatted = formatter.print(date)
Truth.assertThat(formatted).isNotEmpty()
}
}

View file

@ -47,8 +47,9 @@ internal class WalletAccountListFlowFactory @Inject constructor(
return accountsResponseStoreFactory.create(userWallet.walletId).data
.filterNotNull()
.filter { it.accounts.isNotEmpty() }
.distinctUntilChanged()
.map(converter::convert)
.map { converter.convert(it) }
}
private fun createForSingleWallet(userWallet: UserWallet): AccountList {

View file

@ -169,4 +169,42 @@ class WalletAccountListFlowFactoryTest {
accountListConverter.convert(any())
}
}
@Test
fun `create for multi wallet with empty accounts does not emit`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.isMultiCurrency } returns true
}
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
val accountsResponseWithEmptyAccounts = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = null,
sort = null,
totalAccounts = 0,
totalArchivedAccounts = 0,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
)
every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore
every { accountsResponseStore.data } returns accountsResponseStoreFlow
accountsResponseStoreFlow.value = accountsResponseWithEmptyAccounts
// Act
val actual = factory.create(userWalletId).let(::getEmittedValues)
// Assert
Truth.assertThat(actual).isEmpty()
coVerify(inverse = true) {
accountListConverterFactory.create(any())
accountListConverter.convert(any())
}
}
}

View file

@ -10,11 +10,14 @@ import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.concurrent.ConcurrentHashMap
internal class DefaultHotWalletRepository(
private val appPreferencesStore: AppPreferencesStore,
) : HotWalletRepository {
private val firstTopUpDetectedThisSession = ConcurrentHashMap<String, Unit>()
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q)
override fun isWalletCreationSupported(): Boolean {
return BuildConfig.DEBUG || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
@ -50,28 +53,12 @@ internal class DefaultHotWalletRepository(
}
}
override fun shouldShowNextTimeUpgradeBanner(userWalletId: UserWalletId): Flow<Boolean> = appPreferencesStore
.getObjectMap<Boolean>(PreferencesKeys.SHOULD_SHOW_NEXT_TIME_UPGRADE_BANNER_KEY)
.map { it[userWalletId.stringValue] == true }
override suspend fun setShouldShowNextTimeUpgradeBanner(userWalletId: UserWalletId, shouldShow: Boolean) {
appPreferencesStore.editData { mutablePreferences ->
mutablePreferences.setObjectMap(
key = PreferencesKeys.SHOULD_SHOW_NEXT_TIME_UPGRADE_BANNER_KEY,
value = mutablePreferences.getObjectMap<Boolean>(
PreferencesKeys.SHOULD_SHOW_NEXT_TIME_UPGRADE_BANNER_KEY,
)
.plus(userWalletId.stringValue to shouldShow),
)
}
}
override suspend fun getUpgradeBannerClosureTimestamp(userWalletId: UserWalletId): Long? {
return appPreferencesStore
.getObjectMapSync<Long>(PreferencesKeys.UPGRADE_BANNER_CLOSURE_TIMESTAMP_KEY)[userWalletId.stringValue]
}
override suspend fun setUpgradeBannerClosureTimestamp(userWalletId: UserWalletId, timestamp: Long) {
override suspend fun setUpgradeBannerClosureTimestamp(userWalletId: UserWalletId, timestamp: Long?) {
appPreferencesStore.editData { mutablePreferences ->
mutablePreferences.setObjectMap(
key = PreferencesKeys.UPGRADE_BANNER_CLOSURE_TIMESTAMP_KEY,
@ -110,4 +97,12 @@ internal class DefaultHotWalletRepository(
)
}
}
override fun isFirstTopUpDetectedThisSession(userWalletId: UserWalletId): Boolean {
return firstTopUpDetectedThisSession.containsKey(userWalletId.stringValue)
}
override fun markFirstTopUpDetectedThisSession(userWalletId: UserWalletId) {
firstTopUpDetectedThisSession[userWalletId.stringValue] = Unit
}
}

View file

@ -248,7 +248,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
toExtraId = toExtraId,
toExtraId = toExtraId?.ifEmpty { null },
).getOrThrow()
if (dataSignatureVerifier.verifySignature(response.signature, response.txDetailsJson)) {

View file

@ -1,12 +1,14 @@
package com.tangem.data.transaction
import android.net.Uri
import androidx.core.text.isDigitsOnly
import com.tangem.blockchain.blockchains.near.NearWalletManager
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.NameResolver
import com.tangem.blockchain.common.ResolveAddressResult
import com.tangem.blockchain.common.ReverseResolveAddressResult
import com.tangem.blockchain.common.TransactionValidator
import com.tangem.blockchain.common.memo.MemoState
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
@ -16,7 +18,6 @@ import com.tangem.domain.wallets.models.ParsedQrCode
import com.tangem.domain.wallets.models.errors.ParsedQrCodeErrors
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.math.BigInteger
class DefaultWalletAddressServiceRepository(
private val walletManagersFacade: WalletManagersFacade,
@ -95,19 +96,28 @@ class DefaultWalletAddressServiceRepository(
}
}
override fun validateMemo(network: Network, memo: String): Boolean {
if (memo.isEmpty()) return true
return when (network.rawId) {
Blockchain.XRP.id -> {
val tag = memo.toLongOrNull()
tag != null && tag <= XRP_TAG_MAX_NUMBER
override suspend fun validateMemo(userWalletId: UserWalletId, network: Network, memo: String): Boolean =
withContext(dispatchers.io) {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
network = network,
) ?: return@withContext true
val memoStateResult = (walletManager as? TransactionValidator)?.validateMemo(memo)
if (memoStateResult != null) {
when (memoStateResult) {
is Result.Success -> when (memoStateResult.data) {
MemoState.NotSupported,
MemoState.Valid,
-> true
MemoState.Invalid -> false
}
is Result.Failure -> true
}
} else {
true
}
Blockchain.Stellar.id -> {
isAssignableXlmValue(memo)
}
else -> true
}
}
override suspend fun parseSharedAddress(input: String, network: Network): ParsedQrCode {
val blockchain = network.toBlockchain()
@ -145,26 +155,4 @@ class DefaultWalletAddressServiceRepository(
private fun Blockchain.isNear(): Boolean {
return this == Blockchain.Near || this == Blockchain.NearTestnet
}
private fun isAssignableXlmValue(value: String): Boolean {
return when {
value.isNotEmpty() && value.isDigitsOnly() -> {
try {
// from com.tangem.blockchain.blockchains.stellar.StellarMemo.toStellarSdkMemo
value.toBigInteger() in BigInteger.ZERO..Long.MAX_VALUE.toBigInteger() * 2.toBigInteger()
} catch (ex: NumberFormatException) {
false
}
}
else -> {
// from org.stellar.sdk.MemoText
value.toByteArray().size <= XLM_MEMO_MAX_LENGTH
}
}
}
companion object {
private const val XLM_MEMO_MAX_LENGTH = 28
private const val XRP_TAG_MAX_NUMBER = 4294967295
}
}

View file

@ -36,6 +36,7 @@ dependencies {
implementation(projects.domain.walletManager)
implementation(projects.domain.quotes)
implementation(projects.domain.common)
implementation(projects.features.swap.domain)
/** Feature API - remove after removing [HotWalletFeatureToggles] */
implementation(projects.features.hotWallet.api)

View file

@ -45,7 +45,7 @@ internal interface TangemPayDataModule {
@Binds
@Singleton
fun bindTangemPaySwapRepository(repository: DefaultTangemPaySwapRepository): TangemPaySwapRepository
fun bindTangemPaySwapRepository(repository: DefaultTangemPayWithdrawRepository): TangemPayWithdrawRepository
@Binds
@Singleton

View file

@ -0,0 +1,19 @@
package com.tangem.data.pay.entity
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
data class WithdrawStoreData(
@Json(name = "orderId") val orderId: String,
@Json(name = "exchangeData") val exchangeData: ExchangeStoreData?,
)
@JsonClass(generateAdapter = false)
data class ExchangeStoreData(
@Json(name = "txId") val txId: String,
@Json(name = "fromNetwork") val fromNetwork: String,
@Json(name = "fromAddress") val fromAddress: String,
@Json(name = "payInAddress") val payInAddress: String,
@Json(name = "payInExtraId") val payInExtraId: String?,
)

View file

@ -2,8 +2,8 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.visa.error.VisaApiError
@ -12,35 +12,23 @@ import javax.inject.Inject
internal class DefaultCustomerOrderRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayStorage: TangemPayStorage,
) : CustomerOrderRepository {
override suspend fun getOrderStatus(
userWalletId: UserWalletId,
orderId: String,
): Either<VisaApiError, OrderStatus> {
override suspend fun getOrderData(userWalletId: UserWalletId, orderId: String): Either<VisaApiError, OrderData> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getOrder(authHeader = authHeader, orderId = orderId)
}.map { response ->
when (response.result?.status) {
val status = when (response.result?.status) {
null -> OrderStatus.UNKNOWN
OrderStatus.NEW.apiName -> OrderStatus.NEW
OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING
OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED
else -> OrderStatus.CANCELED
}
OrderData(
status = status,
withdrawTxHash = response.result?.data?.transactionHash?.ifEmpty { null },
)
}
}
override suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean {
val orderId = tangemPayStorage.getWithdrawOrderId(userWalletId)
if (orderId == null) return false
val status = getOrderStatus(userWalletId, orderId).getOrNull()
val hasActiveOrder = status == OrderStatus.NEW || status == OrderStatus.PROCESSING
if (!hasActiveOrder) tangemPayStorage.deleteWithdrawOrder(userWalletId)
return hasActiveOrder
}
}

View file

@ -1,99 +0,0 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.left
import com.tangem.core.error.UniversalError
import com.tangem.data.common.quote.QuotesFetcher
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.WithdrawDataRequest
import com.tangem.datasource.api.pay.models.request.WithdrawRequest
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.pay.WithdrawalResult
import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.repository.TangemPaySwapRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.extensions.addHexPrefix
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Currency
import java.util.Locale
import javax.inject.Inject
@Suppress("LongParameterList")
internal class DefaultTangemPaySwapRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val authDataSource: TangemPayAuthDataSource,
private val quotesFetcher: QuotesFetcher,
private val tangemPayStorage: TangemPayStorage,
) : TangemPaySwapRepository {
override suspend fun withdraw(
userWallet: UserWallet,
receiverAddress: String,
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
): Either<UniversalError, WithdrawalResult> {
val amountInCents = getAmountInCents(cryptoAmount, cryptoCurrencyId)
if (amountInCents.isNullOrEmpty()) return Either.Left(VisaApiError.WithdrawalDataError)
return requestHelper.performRequest(userWallet.walletId) { authHeader ->
val request = WithdrawDataRequest(amountInCents = amountInCents, recipientAddress = receiverAddress)
tangemPayApi.getWithdrawData(authHeader = authHeader, body = request)
}.map { data ->
val result = data.result ?: return VisaApiError.WithdrawalDataError.left()
val signatureResult = authDataSource.getWithdrawalSignature(
userWallet = userWallet,
hash = result.hash,
).getOrNull()
return when (signatureResult) {
is WithdrawalSignatureResult.Cancelled -> {
Either.Right(WithdrawalResult.Cancelled)
}
is WithdrawalSignatureResult.Success -> {
requestHelper.performRequest(userWallet.walletId) { authHeader ->
val request = WithdrawRequest(
amountInCents = amountInCents,
recipientAddress = receiverAddress,
adminSalt = result.salt,
senderAddress = result.senderAddress,
adminSignature = signatureResult.signature.addHexPrefix(),
)
tangemPayApi.withdraw(authHeader = authHeader, body = request)
}
.mapLeft { return Either.Left(VisaApiError.WithdrawError) }
.map { response ->
val orderId = response.result?.orderId
if (orderId != null) tangemPayStorage.storeWithdrawOrder(userWallet.walletId, orderId)
WithdrawalResult.Success
}
}
null -> return Either.Left(VisaApiError.SignWithdrawError)
}
}
}
private suspend fun getAmountInCents(cryptoAmount: BigDecimal, cryptoCurrencyId: CryptoCurrency.RawID): String? {
val fiatRate = getFiatRate(cryptoCurrencyId) ?: return null
val amountInDollars = cryptoAmount.multiply(fiatRate)
val defaultFractionDigits = Currency.getInstance(Locale.US).defaultFractionDigits
return amountInDollars
.setScale(defaultFractionDigits, RoundingMode.HALF_UP)
.movePointRight(defaultFractionDigits)
.longValueExact()
.toString()
}
private suspend fun getFiatRate(cryptoCurrencyId: CryptoCurrency.RawID): BigDecimal? {
val quotes = quotesFetcher.fetch(
fiatCurrencyId = Currency.getInstance(Locale.US).currencyCode,
currencyId = cryptoCurrencyId.value,
field = QuotesFetcher.Field.PRICE,
).getOrNull()
return quotes?.quotes[cryptoCurrencyId.value]?.price
}
}

View file

@ -0,0 +1,301 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.core.error.UniversalError
import com.tangem.data.common.quote.QuotesFetcher
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.WithdrawDataRequest
import com.tangem.datasource.api.pay.models.request.WithdrawRequest
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.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.pay.TangemPayWithdrawState
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.utils.extensions.addHexPrefix
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Currency
import java.util.Locale
import javax.inject.Inject
import kotlin.collections.set
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds
private const val TAG = "TangemPaySwapRepository"
@Suppress("LongParameterList")
internal class DefaultTangemPayWithdrawRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val authDataSource: TangemPayAuthDataSource,
private val quotesFetcher: QuotesFetcher,
private val tangemPayStorage: TangemPayStorage,
private val swapRepository: SwapRepository,
private val orderRepository: CustomerOrderRepository,
) : TangemPayWithdrawRepository {
private val withdrawPollingScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val withdrawPollingJobs = mutableMapOf<String, Job>()
private val withdrawPollingMutex = Mutex()
override suspend fun withdraw(
userWallet: UserWallet,
receiverAddress: String,
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
exchangeData: TangemPayWithdrawExchangeState,
): Either<UniversalError, WithdrawalResult> {
val amountInCents = getAmountInCents(cryptoAmount, cryptoCurrencyId)
if (amountInCents.isNullOrEmpty()) return Either.Left(VisaApiError.WithdrawalDataError)
return requestHelper.performRequest(userWallet.walletId) { authHeader ->
val request = WithdrawDataRequest(amountInCents = amountInCents, recipientAddress = receiverAddress)
tangemPayApi.getWithdrawData(authHeader = authHeader, body = request)
}.map { data ->
val result = data.result ?: return VisaApiError.WithdrawalDataError.left()
val signatureResult = authDataSource.getWithdrawalSignature(
userWallet = userWallet,
hash = result.hash,
).getOrNull()
return when (signatureResult) {
is WithdrawalSignatureResult.Cancelled -> {
Either.Right(WithdrawalResult.Cancelled)
}
is WithdrawalSignatureResult.Success -> {
requestHelper.performRequest(userWallet.walletId) { authHeader ->
val request = WithdrawRequest(
amountInCents = amountInCents,
recipientAddress = receiverAddress,
adminSalt = result.salt,
senderAddress = result.senderAddress,
adminSignature = signatureResult.signature.addHexPrefix(),
)
tangemPayApi.withdraw(authHeader = authHeader, body = request)
}
.mapLeft { return Either.Left(VisaApiError.WithdrawError) }
.map { response ->
processWithdrawResult(response, userWallet, exchangeData)
WithdrawalResult.Success
}
}
null -> return Either.Left(VisaApiError.SignWithdrawError)
}
}
}
private suspend fun processWithdrawResult(
response: WithdrawResponse,
userWallet: UserWallet,
exchangeData: TangemPayWithdrawExchangeState,
) {
val orderId = response.result?.orderId
if (orderId != null) {
val orderData = orderRepository
.getOrderData(userWalletId = userWallet.walletId, orderId = orderId).getOrNull()
val withdrawTxHash = orderData?.withdrawTxHash
val storeData = TangemPayWithdrawState(
orderId = orderId,
exchangeData = exchangeData,
)
if (orderData != null && !withdrawTxHash.isNullOrEmpty()) {
finalizeWithdraw(
userWallet = userWallet,
withdrawTxHash = withdrawTxHash,
orderId = orderId,
exchangeData = exchangeData,
order = orderData,
).onLeft {
tangemPayStorage.storeWithdrawOrder(userWalletId = userWallet.walletId, data = storeData)
}
} else {
tangemPayStorage.storeWithdrawOrder(userWalletId = userWallet.walletId, data = storeData)
}
}
}
private suspend fun finalizeWithdraw(
userWallet: UserWallet,
withdrawTxHash: String,
orderId: String,
exchangeData: TangemPayWithdrawExchangeState,
order: OrderData,
): Either<ExpressDataError, Unit> {
return swapRepository.exchangeSent(
userWallet = userWallet,
txId = exchangeData.txId,
fromNetwork = exchangeData.fromNetwork,
fromAddress = exchangeData.fromAddress,
payInAddress = exchangeData.payInAddress,
txHash = withdrawTxHash,
payInExtraId = exchangeData.payInExtraId,
)
.onRight {
val isActive = order.status == OrderStatus.NEW || order.status == OrderStatus.PROCESSING
if (!isActive) {
tangemPayStorage.deleteWithdrawOrder(userWalletId = userWallet.walletId)
} else {
tangemPayStorage.storeWithdrawOrder(
userWalletId = userWallet.walletId,
data = TangemPayWithdrawState(orderId = orderId, exchangeData = null),
)
}
}
.onLeft { error ->
Timber.tag(TAG).e(error.toString())
}
}
override suspend fun hasWithdrawOrder(userWallet: UserWallet): Boolean {
val orderExchangeData = tangemPayStorage.getWithdrawOrder(userWallet.walletId)
if (orderExchangeData == null) return false
val exchangeData = orderExchangeData.exchangeData
val orderData = orderRepository
.getOrderData(userWallet.walletId, orderId = orderExchangeData.orderId).getOrNull()
val withdrawTxHash = orderData?.withdrawTxHash
if (exchangeData != null && orderData != null && withdrawTxHash != null) {
finalizeWithdraw(
userWallet = userWallet,
withdrawTxHash = withdrawTxHash,
orderId = orderExchangeData.orderId,
exchangeData = exchangeData,
order = orderData,
)
}
return orderData?.status == OrderStatus.NEW || orderData?.status == OrderStatus.PROCESSING
}
override suspend fun pollWithdrawOrderIfNeeds(userWallet: UserWallet): Either<VisaApiError, Unit> {
val storeData = tangemPayStorage.getWithdrawOrder(userWallet.walletId) ?: return Unit.right()
val exchangeData = storeData.exchangeData ?: return Unit.right()
val orderId = storeData.orderId
val order = orderRepository
.getOrderData(userWalletId = userWallet.walletId, orderId = orderId).getOrNull()
?: return Unit.right()
val txHash = order.withdrawTxHash
if (!txHash.isNullOrEmpty()) {
finalizeWithdraw(
userWallet = userWallet,
withdrawTxHash = txHash,
orderId = storeData.orderId,
exchangeData = exchangeData,
order = order,
).onLeft {
startWithdrawOrderPolling(
userWallet = userWallet,
orderId = orderId,
storeData = storeData,
exchangeData = exchangeData,
)
}
} else {
startWithdrawOrderPolling(
userWallet = userWallet,
orderId = orderId,
storeData = storeData,
exchangeData = exchangeData,
)
}
return Unit.right()
}
private suspend fun startWithdrawOrderPolling(
userWallet: UserWallet,
orderId: String,
storeData: TangemPayWithdrawState,
exchangeData: TangemPayWithdrawExchangeState,
) {
withdrawPollingMutex.withLock {
if (withdrawPollingJobs.containsKey(orderId)) return
val pollingJob = withdrawPollingScope.launch {
try {
while (isActive && withdrawPollingJobs.containsKey(orderId)) {
delay(duration = 5.seconds)
val orderData = orderRepository
.getOrderData(userWalletId = userWallet.walletId, orderId = orderId)
orderData.onRight { order ->
if (order.status != OrderStatus.NEW && order.status != OrderStatus.PROCESSING) {
tangemPayStorage.deleteWithdrawOrder(userWallet.walletId)
withdrawPollingJobs.remove(key = orderId)
return@launch
}
val txHash = order.withdrawTxHash
if (!txHash.isNullOrEmpty()) {
finalizeWithdraw(
userWallet = userWallet,
withdrawTxHash = txHash,
orderId = storeData.orderId,
exchangeData = exchangeData,
order = order,
).onRight {
withdrawPollingJobs.remove(key = orderId)
return@launch
}
}
}.onLeft { error ->
Timber.tag(TAG).e("error ${error.errorCode}")
}
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
Timber.tag(TAG).e(exception)
withdrawPollingMutex.withLock { withdrawPollingJobs.remove(orderId) }
}
}
withdrawPollingJobs[orderId] = pollingJob
}
}
private suspend fun getAmountInCents(cryptoAmount: BigDecimal, cryptoCurrencyId: CryptoCurrency.RawID): String? {
val fiatRate = getFiatRate(cryptoCurrencyId) ?: return null
val amountInDollars = cryptoAmount.multiply(fiatRate)
val defaultFractionDigits = Currency.getInstance(Locale.US).defaultFractionDigits
return amountInDollars
.setScale(defaultFractionDigits, RoundingMode.HALF_UP)
.movePointRight(defaultFractionDigits)
.longValueExact()
.toString()
}
private suspend fun getFiatRate(cryptoCurrencyId: CryptoCurrency.RawID): BigDecimal? {
val quotes = quotesFetcher.fetch(
fiatCurrencyId = Currency.getInstance(Locale.US).currencyCode,
currencyId = cryptoCurrencyId.value,
field = QuotesFetcher.Field.PRICE,
).getOrNull()
return quotes?.quotes[cryptoCurrencyId.value]?.price
}
}

View file

@ -4,14 +4,15 @@ import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.repository.TangemPaySwapRepository
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import java.math.BigDecimal
import javax.inject.Inject
internal class DefaultTangemPayWithdrawUseCase @Inject constructor(
private val repository: TangemPaySwapRepository,
private val repository: TangemPayWithdrawRepository,
) : TangemPayWithdrawUseCase {
override suspend fun invoke(
@ -19,12 +20,14 @@ internal class DefaultTangemPayWithdrawUseCase @Inject constructor(
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
receiverCexAddress: String,
exchangeData: TangemPayWithdrawExchangeState,
): Either<UniversalError, WithdrawalResult> {
return repository.withdraw(
userWallet = userWallet,
cryptoAmount = cryptoAmount,
receiverAddress = receiverCexAddress,
cryptoCurrencyId = cryptoCurrencyId,
exchangeData = exchangeData,
)
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.data.pay.util
import com.tangem.data.pay.entity.WithdrawStoreData
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.pay.TangemPayWithdrawState
import com.tangem.utils.converter.Converter
class WithdrawStateConverter : Converter<WithdrawStoreData, TangemPayWithdrawState> {
override fun convert(value: WithdrawStoreData): TangemPayWithdrawState = TangemPayWithdrawState(
orderId = value.orderId,
exchangeData = value.exchangeData?.let { exchangeData ->
TangemPayWithdrawExchangeState(
txId = exchangeData.txId,
fromNetwork = exchangeData.fromNetwork,
fromAddress = exchangeData.fromAddress,
payInAddress = exchangeData.payInAddress,
payInExtraId = exchangeData.payInExtraId,
)
},
)
}

View file

@ -0,0 +1,22 @@
package com.tangem.data.pay.util
import com.tangem.data.pay.entity.ExchangeStoreData
import com.tangem.data.pay.entity.WithdrawStoreData
import com.tangem.domain.pay.TangemPayWithdrawState
import com.tangem.utils.converter.Converter
class WithdrawStoreDataConverter : Converter<TangemPayWithdrawState, WithdrawStoreData> {
override fun convert(value: TangemPayWithdrawState): WithdrawStoreData = WithdrawStoreData(
orderId = value.orderId,
exchangeData = value.exchangeData?.let { exchangeData ->
ExchangeStoreData(
txId = exchangeData.txId,
fromNetwork = exchangeData.fromNetwork,
fromAddress = exchangeData.fromAddress,
payInAddress = exchangeData.payInAddress,
payInExtraId = exchangeData.payInExtraId,
)
},
)
}

View file

@ -14,4 +14,10 @@ dependencies {
implementation(projects.domain.wallets.models)
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
}

View file

@ -0,0 +1,61 @@
package com.tangem.domain.hotwallet
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.wallet.UserWalletId
import java.util.concurrent.TimeUnit
class CheckHotWalletUpgradeBannerUseCase(
private val hotWalletRepository: HotWalletRepository,
) {
suspend operator fun invoke(
walletId: UserWalletId,
hasBalance: Boolean,
shouldShowUpgradeBanner: Boolean,
): Either<Throwable, Boolean> = try {
val currentTime = System.currentTimeMillis()
val creationTimestamp = hotWalletRepository.getWalletCreationTimestamp(walletId)
val creationTimestampActual = if (creationTimestamp == null) {
// If creationTimestamp is null (wallet was created before this feature was released),
// store the current timestamp and use it below
hotWalletRepository.setWalletCreationTimestamp(walletId, currentTime)
currentTime
} else {
creationTimestamp
}
val closureTimestamp = hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId)
val hasHadFirstTopUp = hotWalletRepository.hasHadFirstTopUp(walletId)
val daysSinceCreation = TimeUnit.MILLISECONDS.toDays(currentTime - creationTimestampActual)
val daysSinceClosure = closureTimestamp?.let { TimeUnit.MILLISECONDS.toDays(currentTime - it) }
// Wallet balance is positive, but the first top-up hasn't been tracked yet
if (hasBalance && !hasHadFirstTopUp) {
hotWalletRepository.setHasHadFirstTopUp(walletId, true)
hotWalletRepository.setShouldShowUpgradeBanner(walletId, true)
hotWalletRepository.setUpgradeBannerClosureTimestamp(walletId, null)
hotWalletRepository.markFirstTopUpDetectedThisSession(walletId)
}
val shouldShow = when {
// Banner should be shown (e.g., because of the first top-up in the previous session)
shouldShowUpgradeBanner -> !hotWalletRepository.isFirstTopUpDetectedThisSession(walletId)
// Banner was closed; it happened more than BANNER_RESHOW_DAYS (30) days ago
closureTimestamp != null && daysSinceClosure != null && daysSinceClosure >= BANNER_RESHOW_DAYS -> true
// Banner hasn't been closed; wallet was created more than BANNER_RESHOW_DAYS (30) days ago
closureTimestamp == null && daysSinceCreation >= BANNER_RESHOW_DAYS -> true
else -> false
}
shouldShow.right()
} catch (e: Exception) {
e.left()
}
companion object {
const val BANNER_RESHOW_DAYS = 30L
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.hotwallet
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.wallet.UserWalletId
class CloseHotWalletUpgradeBannerUseCase(
private val hotWalletRepository: HotWalletRepository,
) {
suspend operator fun invoke(walletId: UserWalletId): Either<Throwable, Unit> = try {
val currentTime = System.currentTimeMillis()
hotWalletRepository.setShouldShowUpgradeBanner(walletId, false)
hotWalletRepository.setUpgradeBannerClosureTimestamp(walletId, currentTime)
Unit.right()
} catch (e: Exception) {
e.left()
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.hotwallet
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
class ShouldShowUpgradeHotWalletBannerUseCase(
private val hotWalletRepository: HotWalletRepository,
) {
operator fun invoke(userWalletId: UserWalletId): Flow<Boolean> =
hotWalletRepository.shouldShowUpgradeBanner(userWalletId)
}

View file

@ -17,13 +17,9 @@ interface HotWalletRepository {
suspend fun setShouldShowUpgradeBanner(userWalletId: UserWalletId, shouldShow: Boolean)
fun shouldShowNextTimeUpgradeBanner(userWalletId: UserWalletId): Flow<Boolean>
suspend fun setShouldShowNextTimeUpgradeBanner(userWalletId: UserWalletId, shouldShow: Boolean)
suspend fun getUpgradeBannerClosureTimestamp(userWalletId: UserWalletId): Long?
suspend fun setUpgradeBannerClosureTimestamp(userWalletId: UserWalletId, timestamp: Long)
suspend fun setUpgradeBannerClosureTimestamp(userWalletId: UserWalletId, timestamp: Long?)
suspend fun getWalletCreationTimestamp(userWalletId: UserWalletId): Long?
@ -32,4 +28,8 @@ interface HotWalletRepository {
suspend fun hasHadFirstTopUp(userWalletId: UserWalletId): Boolean
suspend fun setHasHadFirstTopUp(userWalletId: UserWalletId, hasTopUp: Boolean)
fun isFirstTopUpDetectedThisSession(userWalletId: UserWalletId): Boolean
fun markFirstTopUpDetectedThisSession(userWalletId: UserWalletId)
}

View file

@ -0,0 +1,257 @@
package com.tangem.domain.hotwallet
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.util.concurrent.TimeUnit
class CheckHotWalletUpgradeBannerUseCaseTest {
private val hotWalletRepository: HotWalletRepository = mockk(relaxed = true)
private val useCase = CheckHotWalletUpgradeBannerUseCase(hotWalletRepository)
private val walletId = UserWalletId("0123456789ABCDEF")
@Test
fun `GIVEN creation timestamp is null WHEN invoke THEN set timestamp and return false`() = runTest {
coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns null
coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null
coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns false
every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false
val result = useCase(
walletId = walletId,
hasBalance = false,
shouldShowUpgradeBanner = false,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
assertThat((result as Either.Right).value).isFalse()
coVerify { hotWalletRepository.setWalletCreationTimestamp(walletId, any()) }
}
@Test
fun `GIVEN shouldShowUpgradeBanner is true and hasBalance WHEN invoke THEN return true`() = runTest {
val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5)
coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp
coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null
coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns true
every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false
val result = useCase(
walletId = walletId,
hasBalance = true,
shouldShowUpgradeBanner = true,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
assertThat((result as Either.Right).value).isTrue()
}
@Test
fun `GIVEN shouldShowUpgradeBanner is true WHEN invoke THEN return true regardless of balance`() = runTest {
val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5)
coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp
coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null
coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns true
every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false
val result = useCase(
walletId = walletId,
hasBalance = false,
shouldShowUpgradeBanner = true,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
assertThat((result as Either.Right).value).isTrue()
}
@Test
fun `GIVEN closure timestamp exists and 30 days since closure WHEN invoke THEN return true`() = runTest {
val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(60)
val closureTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31)
coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp
coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns closureTimestamp
coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns true
every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false
val result = useCase(
walletId = walletId,
hasBalance = true,
shouldShowUpgradeBanner = false,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
assertThat((result as Either.Right).value).isTrue()
}
@Test
fun `GIVEN closure timestamp exists but less than 30 days since closure WHEN invoke THEN return false`() =
runTest {
val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(60)
val closureTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(15)
coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp
coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns closureTimestamp
coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns true
every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false
val result = useCase(
walletId = walletId,
hasBalance = true,
shouldShowUpgradeBanner = false,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
assertThat((result as Either.Right).value).isFalse()
}
@Test
fun `GIVEN no flags set and no closure and 30 days since creation WHEN invoke THEN return true`() = runTest {
val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31)
coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp
coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null
coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns false
every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false
val result = useCase(
walletId = walletId,
hasBalance = false,
shouldShowUpgradeBanner = false,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
assertThat((result as Either.Right).value).isTrue()
}
@Test
fun `GIVEN no flags set but less than 30 days since creation WHEN invoke THEN return false`() = runTest {
val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(15)
coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp
coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null
coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns false
every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false
val result = useCase(
walletId = walletId,
hasBalance = false,
shouldShowUpgradeBanner = false,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
assertThat((result as Either.Right).value).isFalse()
}
@Test
fun `GIVEN no flags set but closure timestamp exists WHEN invoke THEN return false`() = runTest {
val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(60)
val closureTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5)
coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp
coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns closureTimestamp
coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns false
every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false
val result = useCase(
walletId = walletId,
hasBalance = false,
shouldShowUpgradeBanner = false,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
assertThat((result as Either.Right).value).isFalse()
}
@Test
fun `GIVEN first top-up detected WHEN invoke THEN return false and mark session`() = runTest {
val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5)
coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp
coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null
coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns false
every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false
val result = useCase(
walletId = walletId,
hasBalance = true,
shouldShowUpgradeBanner = false,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
assertThat((result as Either.Right).value).isFalse()
coVerify { hotWalletRepository.setHasHadFirstTopUp(walletId, true) }
coVerify { hotWalletRepository.setShouldShowUpgradeBanner(walletId, true) }
coVerify { hotWalletRepository.setUpgradeBannerClosureTimestamp(walletId, null) }
verify { hotWalletRepository.markFirstTopUpDetectedThisSession(walletId) }
}
@Test
fun `GIVEN first top-up detected this session WHEN invoke THEN return false`() = runTest {
val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5)
coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp
coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null
coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns true
every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns true
val result = useCase(
walletId = walletId,
hasBalance = true,
shouldShowUpgradeBanner = true,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
assertThat((result as Either.Right).value).isFalse()
}
@Test
fun `GIVEN already had first top-up WHEN invoke with balance THEN do not set flags again`() = runTest {
val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(5)
coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp
coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null
coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns true
every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false
useCase(
walletId = walletId,
hasBalance = true,
shouldShowUpgradeBanner = true,
)
coVerify(exactly = 0) { hotWalletRepository.setHasHadFirstTopUp(any(), any()) }
coVerify(exactly = 0) { hotWalletRepository.setShouldShowUpgradeBanner(any(), any()) }
}
@Test
fun `GIVEN multiple re-emissions with same state WHEN invoke THEN return same result`() = runTest {
val creationTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31)
coEvery { hotWalletRepository.getWalletCreationTimestamp(walletId) } returns creationTimestamp
coEvery { hotWalletRepository.getUpgradeBannerClosureTimestamp(walletId) } returns null
coEvery { hotWalletRepository.hasHadFirstTopUp(walletId) } returns false
every { hotWalletRepository.isFirstTopUpDetectedThisSession(walletId) } returns false
val result1 = useCase(
walletId = walletId,
hasBalance = false,
shouldShowUpgradeBanner = false,
)
val result2 = useCase(
walletId = walletId,
hasBalance = false,
shouldShowUpgradeBanner = false,
)
val result3 = useCase(
walletId = walletId,
hasBalance = false,
shouldShowUpgradeBanner = false,
)
assertThat((result1 as Either.Right).value).isTrue()
assertThat((result2 as Either.Right).value).isTrue()
assertThat((result3 as Either.Right).value).isTrue()
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.domain.hotwallet
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
class CloseHotWalletUpgradeBannerUseCaseTest {
private val hotWalletRepository: HotWalletRepository = mockk(relaxed = true)
private val useCase = CloseHotWalletUpgradeBannerUseCase(hotWalletRepository)
private val walletId = UserWalletId("0123456789ABCDEF")
@Test
fun `WHEN invoke THEN set banner flag to false and closure timestamp`() = runTest {
val result = useCase(walletId)
assertThat(result).isInstanceOf(Either.Right::class.java)
coVerify { hotWalletRepository.setShouldShowUpgradeBanner(walletId, false) }
coVerify { hotWalletRepository.setUpgradeBannerClosureTimestamp(walletId, any()) }
}
@Test
fun `GIVEN repository throws exception WHEN invoke THEN return Either Left`() = runTest {
val exception = RuntimeException("Test error")
coEvery { hotWalletRepository.setShouldShowUpgradeBanner(walletId, false) } throws exception
val result = useCase(walletId)
assertThat(result).isInstanceOf(Either.Left::class.java)
assertThat((result as Either.Left).value).isEqualTo(exception)
}
}

View file

@ -23,7 +23,7 @@ interface WalletAddressServiceRepository {
suspend fun validateAddress(userWalletId: UserWalletId, network: Network, address: String): Boolean
fun validateMemo(network: Network, memo: String): Boolean
suspend fun validateMemo(userWalletId: UserWalletId, network: Network, memo: String): Boolean
suspend fun parseSharedAddress(input: String, network: Network): ParsedQrCode
}

View file

@ -3,7 +3,8 @@ package com.tangem.domain.transaction.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.WalletAddressServiceRepository
import com.tangem.domain.transaction.error.ValidateMemoError
@ -14,9 +15,17 @@ class ValidateWalletMemoUseCase(
private val walletAddressServiceRepository: WalletAddressServiceRepository,
) {
operator fun invoke(network: Network, memo: String): Either<ValidateMemoError, Unit> {
suspend operator fun invoke(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
memo: String,
): Either<ValidateMemoError, Unit> {
return try {
val isValidMemo = walletAddressServiceRepository.validateMemo(network, memo)
val isValidMemo = walletAddressServiceRepository.validateMemo(
userWalletId = userWalletId,
network = cryptoCurrency.network,
memo = memo,
)
if (isValidMemo) {
Unit.right()
} else {

View file

@ -24,6 +24,7 @@ dependencies {
implementation(projects.domain.core)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.features.swap.domain)
/** Security */

View file

@ -0,0 +1,14 @@
package com.tangem.domain.pay
data class TangemPayWithdrawState(
val orderId: String,
val exchangeData: TangemPayWithdrawExchangeState?,
)
data class TangemPayWithdrawExchangeState(
val txId: String,
val fromNetwork: String,
val fromAddress: String,
val payInAddress: String,
val payInExtraId: String?,
)

View file

@ -0,0 +1,6 @@
package com.tangem.domain.pay.model
data class OrderData(
val status: OrderStatus,
val withdrawTxHash: String?,
)

View file

@ -2,12 +2,10 @@ package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.visa.error.VisaApiError
interface CustomerOrderRepository {
suspend fun getOrderStatus(userWalletId: UserWalletId, orderId: String): Either<VisaApiError, OrderStatus>
suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean
suspend fun getOrderData(userWalletId: UserWalletId, orderId: String): Either<VisaApiError, OrderData>
}

View file

@ -4,15 +4,22 @@ import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.visa.error.VisaApiError
import java.math.BigDecimal
interface TangemPaySwapRepository {
interface TangemPayWithdrawRepository {
suspend fun withdraw(
userWallet: UserWallet,
receiverAddress: String,
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
exchangeData: TangemPayWithdrawExchangeState,
): Either<UniversalError, WithdrawalResult>
suspend fun hasWithdrawOrder(userWallet: UserWallet): Boolean
suspend fun pollWithdrawOrderIfNeeds(userWallet: UserWallet): Either<VisaApiError, Unit>
}

View file

@ -137,13 +137,13 @@ class TangemPayMainScreenCustomerInfoUseCase(
userWalletId: UserWalletId,
orderId: String,
): Either<TangemPayCustomerInfoError, MainScreenCustomerInfo> {
return customerOrderRepository.getOrderStatus(userWalletId, orderId = orderId)
return customerOrderRepository.getOrderData(userWalletId, orderId = orderId)
.fold(
ifLeft = { error ->
error.mapErrorForCustomer().left()
},
ifRight = { orderStatus ->
when (orderStatus) {
ifRight = { orderData ->
when (orderData.status) {
// Kyc is passed and user waits for order creation -> no need to get customer info
OrderStatus.NEW,
OrderStatus.PROCESSING,
@ -154,7 +154,7 @@ class TangemPayMainScreenCustomerInfoUseCase(
kycStatus = CustomerInfo.KycStatus.APPROVED,
cardInfo = null,
),
orderStatus = orderStatus,
orderStatus = orderData.status,
).right()
// Order was created/cancelled -> clear order id and get customer info
@ -164,11 +164,11 @@ class TangemPayMainScreenCustomerInfoUseCase(
-> {
onboardingRepository.clearOrderId(userWalletId)
// If order was cancelled -> start order creation
if (orderStatus == OrderStatus.CANCELED) onboardingRepository.createOrder(userWalletId)
if (orderData.status == OrderStatus.CANCELED) onboardingRepository.createOrder(userWalletId)
onboardingRepository.getCustomerInfo(userWalletId = userWalletId)
.mapLeft { it.mapErrorForCustomer() }
.map { customerInfo ->
MainScreenCustomerInfo(info = customerInfo, orderStatus = orderStatus)
MainScreenCustomerInfo(info = customerInfo, orderStatus = orderData.status)
}
}
}

View file

@ -4,6 +4,7 @@ import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.pay.WithdrawalResult
import java.math.BigDecimal
@ -14,5 +15,6 @@ interface TangemPayWithdrawUseCase {
cryptoAmount: BigDecimal,
cryptoCurrencyId: CryptoCurrency.RawID,
receiverCexAddress: String,
exchangeData: TangemPayWithdrawExchangeState,
): Either<UniversalError, WithdrawalResult>
}

View file

@ -171,6 +171,10 @@ sealed class WalletSettingsAnalyticEvents(
event = "Wallet Upgraded",
), AppsFlyerIncludedEvent
class WalletsReorder : WalletSettingsAnalyticEvents(
event = "Longtap - Wallets Order",
)
enum class RecoveryPhraseScreenAction(val value: String) {
Upgrade("Upgrade"),
Backup("Backup"),

View file

@ -18,6 +18,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.vectorResource
@ -39,6 +40,7 @@ import com.tangem.core.ui.components.fields.AutoSizeTextField
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.account.createedit.entity.AccountCreateEditUM
import com.tangem.features.account.createedit.entity.AccountCreateEditUM.Account
@ -53,11 +55,12 @@ internal fun AccountCreateEditContent(
) {
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val nestedScrollConnection = rememberHideKeyboardNestedScrollConnection()
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.secondary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
@ -65,6 +68,7 @@ internal fun AccountCreateEditContent(
Column(
modifier = Modifier
.nestedScroll(nestedScrollConnection)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.weight(1f),

View file

@ -13,6 +13,7 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
import com.tangem.domain.wallets.usecase.ApplyUserWalletListSortingUseCase
import com.tangem.domain.wallets.usecase.UnlockWalletUseCase
import com.tangem.features.details.entity.UserWalletListUM
@ -138,10 +139,11 @@ internal class UserWalletListModel @Inject constructor(
val userWalletIds = state.value.userWallets.map { UserWalletId(it.id) }
modelScope.launch {
applyUserWalletListSortingUseCase(userWalletIds)
.onLeft { error ->
Timber.e("Failed to apply wallet list sorting: $error")
}
applyUserWalletListSortingUseCase(userWalletIds).onRight {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.WalletsReorder())
}.onLeft { error ->
Timber.e("Failed to apply wallet list sorting: $error")
}
}
}
}

View file

@ -66,7 +66,7 @@ class CheckCurrencyUnsupportedDelegate @Inject constructor(
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
id = R.string.alert_manage_tokens_unsupported_blockchain_by_card_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
},

View file

@ -10,10 +10,13 @@ import com.tangem.utils.StringsSigns
import org.joda.time.DateTime
internal fun mapFormattedDate(createdAt: String): TextReference {
val formattedDate = getFormattedDate(
createdAt = createdAt,
now = DateTime.now(),
)
val formattedDate = runCatching {
getFormattedDate(
createdAt = createdAt,
now = DateTime.now(),
)
}.getOrElse { FormattedDate.FullDate("") }
return when (formattedDate) {
is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date)
is FormattedDate.HoursAgo -> TextReference.PluralRes(

View file

@ -66,7 +66,7 @@ class CheckCurrencyUnsupportedDelegate @Inject constructor(
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
id = R.string.alert_manage_tokens_unsupported_blockchain_by_card_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
},

View file

@ -213,7 +213,7 @@ internal fun LazyListScope.portfolioTokensList(portfolio: TokensListItemUM.Portf
portfolioItem(
portfolio = portfolio,
modifier = Modifier.padding(top = 8.dp),
modifier = Modifier,
isBalanceHidden = isBalanceHidden,
)
if (!isExpanded) return

View file

@ -323,8 +323,9 @@ internal class SendDestinationModel @Inject constructor(
senderAddresses = senderAddresses.value,
)
val memoValidationResult = validateWalletMemoUseCase(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
memo = memo.orEmpty(),
network = cryptoCurrency.network,
)
if (type != null) {

View file

@ -7,6 +7,8 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.swap.v2.impl.notifications.model.SwapNotificationsModel
import com.tangem.features.swap.v2.impl.notifications.ui.swapNotifications
import kotlinx.collections.immutable.ImmutableList
@ -40,6 +42,10 @@ internal class SwapNotificationsComponent(
data class SwapNotificationData(
val expressError: ExpressError?,
val fromCryptoCurrency: CryptoCurrency?,
val destinationAddress: String,
val memo: String? = null,
val toCryptoCurrencyStatus: CryptoCurrencyStatus? = null,
val userWalletId: UserWalletId? = null,
)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.swap.v2.impl.notifications.model
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -7,6 +8,8 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger
import com.tangem.features.swap.v2.impl.notifications.DefaultSwapNotificationsUpdateTrigger
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent
@ -14,6 +17,7 @@ import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsUpdateListener
import com.tangem.features.swap.v2.impl.notifications.entity.SwapNotificationUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import java.math.BigDecimal
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -30,6 +34,7 @@ internal class SwapNotificationsModel @Inject constructor(
private val swapNotificationsUpdateListener: SwapNotificationsUpdateListener,
private val swapNotificationsUpdateTrigger: DefaultSwapNotificationsUpdateTrigger,
private val swapAmountUpdateTrigger: SwapAmountUpdateTrigger,
private val validateTransactionUseCase: ValidateTransactionUseCase,
paramsContainer: ParamsContainer,
) : Model() {
@ -61,12 +66,33 @@ internal class SwapNotificationsModel @Inject constructor(
private suspend fun buildNotifications() {
val notifications = buildList {
addExpressErrorNotification()
addDestinationTagRequiredNotification()
}
swapNotificationsUpdateTrigger.callbackHasError(notifications.isNotEmpty())
uiState.value = notifications.toImmutableList()
}
private suspend fun MutableList<NotificationUM>.addDestinationTagRequiredNotification() {
val toCryptoCurrencyStatus = notificationData.toCryptoCurrencyStatus ?: return
val userWalletId = notificationData.userWalletId ?: return
val destinationAddress = notificationData.destinationAddress
if (destinationAddress.isEmpty()) return
val validationError = validateTransactionUseCase(
amount = BigDecimal.ZERO.convertToSdkAmount(toCryptoCurrencyStatus),
fee = null,
memo = notificationData.memo,
destination = destinationAddress,
userWalletId = userWalletId,
network = toCryptoCurrencyStatus.currency.network,
).leftOrNull()
if (validationError is BlockchainSdkError.DestinationTagRequired) {
add(NotificationUM.Error.DestinationTagRequired)
}
}
fun MutableList<NotificationUM>.addExpressErrorNotification() {
val expressError = notificationData.expressError ?: return
val fromCryptoCurrency = notificationData.fromCryptoCurrency ?: return

View file

@ -133,6 +133,10 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
swapNotificationData = SwapNotificationsComponent.Params.SwapNotificationData(
expressError = (model.confirmData.quote as? SwapQuoteUM.Error)?.expressError,
fromCryptoCurrency = model.confirmData.fromCryptoCurrencyStatus?.currency,
destinationAddress = model.confirmData.enteredDestination.orEmpty(),
memo = model.confirmData.enteredMemo,
toCryptoCurrencyStatus = model.confirmData.toCryptoCurrencyStatus,
userWalletId = params.userWallet.walletId,
),
),
)

View file

@ -429,6 +429,10 @@ internal class SendWithSwapConfirmModel @Inject constructor(
data = SwapNotificationData(
expressError = (confirmData.quote as? SwapQuoteUM.Error)?.expressError,
fromCryptoCurrency = confirmData.fromCryptoCurrencyStatus?.currency,
destinationAddress = confirmData.enteredDestination.orEmpty(),
memo = confirmData.enteredMemo,
toCryptoCurrencyStatus = confirmData.toCryptoCurrencyStatus,
userWalletId = params.userWallet.walletId,
),
)
uiState.transformerUpdate(

View file

@ -312,7 +312,7 @@ internal class DefaultSwapRepository(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
toExtraId = toExtraId,
toExtraId = toExtraId?.ifEmpty { null },
).getOrThrow()
if (dataSignatureVerifier.verifySignature(response.signature, response.txDetailsJson)) {
val txDetails = parseTxDetails(response.txDetailsJson)

View file

@ -38,6 +38,7 @@ dependencies {
implementation(projects.domain.express.models)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.domain.visa.models)
implementation(projects.features.swap.domain.api)
implementation(projects.features.swap.domain.models)

View file

@ -36,6 +36,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.tokens.*
@ -1030,6 +1031,7 @@ internal class SwapInteractorImpl @AssistedInject constructor(
exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError
if (isTangemPayWithdrawal) {
val networkAddress = currencyToSend.value.networkAddress
return SwapTransactionState.TangemPayWithdrawalData(
cryptoAmount = amount.value,
cryptoCurrencyId = requireNotNull(currencyToSend.currency.id.rawCurrencyId),
@ -1056,6 +1058,13 @@ internal class SwapInteractorImpl @AssistedInject constructor(
txExternalId = exchangeDataCex.externalTxId,
averageDuration = null,
),
exchangeData = TangemPayWithdrawExchangeState(
txId = exchangeDataCex.txId,
fromNetwork = currencyToSend.currency.network.backendId,
fromAddress = networkAddress?.defaultAddress?.value.orEmpty(),
payInAddress = exchangeData.transaction.txTo,
payInExtraId = exchangeDataCex.txExtraId,
),
)
}

View file

@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain.models.ui
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
@ -31,6 +32,7 @@ sealed class SwapTransactionState {
val toAmount: String?,
val toAmountValue: BigDecimal?,
val storeData: StoreTransactionData,
val exchangeData: TangemPayWithdrawExchangeState,
) : SwapTransactionState() {
data class StoreTransactionData(

View file

@ -1136,6 +1136,7 @@ internal class SwapModel @Inject constructor(
cryptoAmount = swapTransactionState.cryptoAmount,
cryptoCurrencyId = swapTransactionState.cryptoCurrencyId,
receiverCexAddress = swapTransactionState.cexAddress,
exchangeData = swapTransactionState.exchangeData,
)
.onLeft {
startLoadingQuotesFromLastState()

View file

@ -322,7 +322,7 @@ internal fun LazyListScope.portfolioTokensList(portfolio: TokensListItemUM.Portf
portfolioItem(
portfolio = portfolio,
modifier = Modifier.padding(top = 8.dp),
modifier = Modifier,
isBalanceHidden = isBalanceHidden,
)
if (!isExpanded) return

View file

@ -26,8 +26,8 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayTopUpData
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
@ -79,7 +79,7 @@ internal class TangemPayDetailsModel @Inject constructor(
private val cardDetailsEventListener: CardDetailsEventListener,
private val txHistoryUpdateListener: TangemPayTxHistoryUpdateListener,
private val tangemPayCryptoCurrencyFactory: TangemPayCryptoCurrencyFactory,
private val orderRepository: CustomerOrderRepository,
private val tangemPayWithdrawRepository: TangemPayWithdrawRepository,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val expressTransactionsEventListener: ExpressTransactionsEventListener,
@ -124,6 +124,7 @@ internal class TangemPayDetailsModel @Inject constructor(
modelScope.launch {
expressTransactionsEventListener.send(ExpressTransactionsEvent.Update)
}
subscribeToWithdrawOrder()
}
fun onPause() {
@ -148,6 +149,14 @@ internal class TangemPayDetailsModel @Inject constructor(
.launchIn(modelScope)
}
private fun subscribeToWithdrawOrder() {
modelScope.launch {
val userWallet = userWallet ?: getUserWalletUseCase(params.userWalletId).getOrNull()
?: return@launch
tangemPayWithdrawRepository.pollWithdrawOrderIfNeeds(userWallet)
}
}
override fun onClickPinCode() {
analytics.send(TangemPayAnalyticsEvents.PinCodeClicked())
if (!params.config.isPinSet) {
@ -278,24 +287,28 @@ internal class TangemPayDetailsModel @Inject constructor(
if (currentBalance == null || depositAddress == null) {
showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
} else {
modelScope.launch {
val hasActiveWithdrawal = orderRepository.hasWithdrawOrder(userWalletId = params.userWalletId)
if (hasActiveWithdrawal) {
showBottomSheetError(TangemPayDetailsErrorType.WithdrawInProgress)
} else {
val userWallet = userWallet ?: getUserWalletUseCase(params.userWalletId).getOrNull()
val currency = cryptoCurrency ?: userWallet?.let {
tangemPayCryptoCurrencyFactory.create(userWallet = userWallet, chainId = params.config.chainId)
.getOrNull()
}
if (currency != null) {
uiMessageSender.send(
message = TangemPayMessagesFactory.createWithdrawWarning(
onGotItClick = { onConfirmWithdrawal(currency, currentBalance, depositAddress) },
),
)
val userWallet = userWallet ?: getUserWalletUseCase(params.userWalletId).getOrNull()
if (userWallet == null) {
showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
} else {
modelScope.launch {
val hasActiveWithdrawal = tangemPayWithdrawRepository.hasWithdrawOrder(userWallet = userWallet)
if (hasActiveWithdrawal) {
showBottomSheetError(TangemPayDetailsErrorType.WithdrawInProgress)
} else {
showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
val currency = cryptoCurrency ?: tangemPayCryptoCurrencyFactory.create(
userWallet = userWallet,
chainId = params.config.chainId,
).getOrNull()
if (currency != null) {
uiMessageSender.send(
message = TangemPayMessagesFactory.createWithdrawWarning(
onGotItClick = { onConfirmWithdrawal(currency, currentBalance, depositAddress) },
),
)
} else {
showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
}
}
}
}

View file

@ -17,7 +17,7 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.hotwallet.CloseHotWalletUpgradeBannerUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
@ -135,7 +135,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
private val getWalletsListForEnablingUseCase: GetWalletsForAutomaticallyPushEnablingUseCase,
private val uiMessageSender: UiMessageSender,
private val reviewManager: ReviewManager,
private val hotWalletRepository: HotWalletRepository,
private val closeHotWalletUpgradeBannerUseCase: CloseHotWalletUpgradeBannerUseCase,
) : BaseWalletClickIntents(), WalletWarningsClickIntents {
override fun onAddBackupCardClick() {
@ -554,8 +554,6 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
modelScope.launch(dispatchers.main) {
val userWallet = getUserWalletUseCase(userWalletId).getOrNull()
if (userWallet is UserWallet.Hot) {
hotWalletRepository.setShouldShowUpgradeBanner(userWalletId, false)
hotWalletRepository.setShouldShowNextTimeUpgradeBanner(userWalletId, false)
appRouter.push(UpgradeWallet(userWalletId))
}
}
@ -565,15 +563,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
modelScope.launch(dispatchers.main) {
val userWallet = getUserWalletUseCase(userWalletId).getOrNull()
if (userWallet is UserWallet.Hot) {
val hasHadFirstTopUp = hotWalletRepository.hasHadFirstTopUp(userWalletId)
val currentTime = System.currentTimeMillis()
if (hasHadFirstTopUp) {
hotWalletRepository.setShouldShowUpgradeBanner(userWalletId, false)
hotWalletRepository.setUpgradeBannerClosureTimestamp(userWalletId, currentTime)
} else {
hotWalletRepository.setShouldShowUpgradeBanner(userWalletId, true)
}
closeHotWalletUpgradeBannerUseCase(userWalletId)
}
}
}

View file

@ -10,7 +10,9 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -43,10 +45,13 @@ internal class ExpandedAccountsHolder @Inject constructor(
.toSet()
// main state holder
val expandedAccounts = MutableStateFlow(initExpandedState)
var debounceJob: Job? = null
actionChannel
.filter { (accountId, _) -> accountId.userWalletId == walletId }
.filter { debounceJob?.isActive != true }
.onEach { (accountId, isExpand) ->
debounceJob = launch { delay(DEBOUNCE_MILLIS) }
val newState = AccountExpandedState(accountId, isExpand)
launch { accountsExpandedRepository.update(newState) }
if (isExpand) {
@ -100,4 +105,8 @@ internal class ExpandedAccountsHolder @Inject constructor(
}
private fun walletAccounts(walletId: UserWalletId): Flow<AccountList> = singleAccountListSupplier(walletId)
companion object {
private const val DEBOUNCE_MILLIS = 200L
}
}

View file

@ -13,8 +13,9 @@ import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.hotwallet.CheckHotWalletUpgradeBannerUseCase
import com.tangem.domain.hotwallet.GetAccessCodeSkippedUseCase
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.hotwallet.ShouldShowUpgradeHotWalletBannerUseCase
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
@ -44,7 +45,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -60,7 +61,8 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
private val notificationsRepository: NotificationsRepository,
private val accountDependencies: AccountDependencies,
private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase,
private val hotWalletRepository: HotWalletRepository,
private val shouldShowUpgradeHotWalletBannerUseCase: ShouldShowUpgradeHotWalletBannerUseCase,
private val checkHotWalletUpgradeBannerUseCase: CheckHotWalletUpgradeBannerUseCase,
) {
@Suppress("UNCHECKED_CAST", "MagicNumber", "LongMethod")
@ -101,8 +103,8 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
getAccessCodeSkippedUseCase(userWallet.walletId).distinctUntilChanged(),
shouldShowPromoWalletUseCase(userWalletId = userWallet.walletId, promoId = PromoId.YieldPromo)
.distinctUntilChanged(),
hotWalletRepository.shouldShowUpgradeBanner(userWallet.walletId).distinctUntilChanged(),
hotWalletRepository.shouldShowNextTimeUpgradeBanner(userWallet.walletId).distinctUntilChanged(),
shouldShowUpgradeHotWalletBannerUseCase.invoke(userWallet.walletId)
.distinctUntilChanged(),
) { array -> array }
.combine(tokenListFlow()) { array, any: Any -> arrayOf(any).plus(elements = array) }
.map { array ->
@ -117,7 +119,6 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
val shouldAccessCodeSkipped = array[6] as Boolean
val shouldShowYieldPromo = array[7] as Boolean
val shouldShowUpgradeBanner = array[8] as Boolean
val shouldShowNextTimeUpgradeBanner = array[9] as Boolean
buildList {
addUsedOutdatedDataNotification(totalFiatBalance)
@ -129,7 +130,6 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
flattenCurrencies = flattenCurrencies,
clickIntents = clickIntents,
shouldShowUpgradeBanner = shouldShowUpgradeBanner,
shouldShowNextTimeUpgradeBanner = shouldShowNextTimeUpgradeBanner,
)
addFinishWalletActivationNotification(
@ -464,45 +464,22 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
)
}
@Suppress("CyclomaticComplexMethod")
private suspend fun MutableList<WalletNotification>.addUpgradeHotWalletPromoNotification(
userWallet: UserWallet,
flattenCurrencies: Lce<TokenListError, List<CryptoCurrencyStatus>>,
clickIntents: WalletClickIntents,
shouldShowUpgradeBanner: Boolean,
shouldShowNextTimeUpgradeBanner: Boolean,
) {
if (userWallet !is UserWallet.Hot) return
val currentTime = System.currentTimeMillis()
val creationTimestamp = hotWalletRepository.getWalletCreationTimestamp(userWallet.walletId)
val closureTimestamp = hotWalletRepository.getUpgradeBannerClosureTimestamp(userWallet.walletId)
val hasHadFirstTopUp = hotWalletRepository.hasHadFirstTopUp(userWallet.walletId)
if (creationTimestamp == null) {
hotWalletRepository.setWalletCreationTimestamp(userWallet.walletId, currentTime)
return
}
val daysSinceCreation = TimeUnit.MILLISECONDS.toDays(currentTime - creationTimestamp)
val daysSinceClosure = closureTimestamp?.let { TimeUnit.MILLISECONDS.toDays(currentTime - it) }
val currencies = flattenCurrencies.getOrNull(isPartialContentAccepted = true).orEmpty()
val hasBalance = currencies.any { it.value.amount.orZero().isPositive() }
if (hasBalance && !hasHadFirstTopUp) {
hotWalletRepository.setHasHadFirstTopUp(userWallet.walletId, true)
}
val shouldShow = when {
shouldShowUpgradeBanner && hasBalance -> true
shouldShowNextTimeUpgradeBanner && daysSinceClosure != null && daysSinceClosure >= UPGRADE_BANNER_RESHOW_DAYS -> true
!shouldShowUpgradeBanner && !shouldShowNextTimeUpgradeBanner && hasHadFirstTopUp && daysSinceCreation >= UPGRADE_BANNER_RESHOW_DAYS -> {
hotWalletRepository.setShouldShowNextTimeUpgradeBanner(userWallet.walletId, true)
true
}
else -> false
}
val shouldShow = checkHotWalletUpgradeBannerUseCase(
walletId = userWallet.walletId,
hasBalance = hasBalance,
shouldShowUpgradeBanner = shouldShowUpgradeBanner,
).getOrNull() ?: return
addIf(
element = WalletNotification.UpgradeHotWalletPromo(
@ -515,6 +492,5 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
private companion object {
const val MAX_REMAINING_SIGNATURES_COUNT = 10
const val UPGRADE_BANNER_RESHOW_DAYS = 30
}
}

View file

@ -251,7 +251,7 @@ internal enum class Wallet2CobrandImage(
Sakura(
cards2ResId = R.drawable.ill_sakura_card2_120_106,
cards3ResId = R.drawable.ill_sakura_card3_120_106,
batchIds = setOf("AF990029", "AF990030", "AF990031"),
batchIds = setOf("AF990029", "AF990030", "AF990031", "AF990071", "AF990072", "AF990073"),
),
SatoshiFriends(

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico
tangemBlockchainSdk = "develop-1425"
tangemBlockchainSdk = "releases-5.34-1430"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-578"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^