Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-18 19:37:16 +08:00
commit 6126d65b7a
34 changed files with 390 additions and 237 deletions

View file

@ -42,6 +42,7 @@ import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.tap.common.analytics.CustomerIoFeatureToggles
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient
import com.tangem.tap.common.log.TangemAppLoggerInitializer
@ -148,4 +149,6 @@ interface ApplicationEntryPoint {
fun getABTestsManager(): ABTestsManager
fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory
fun getCustomerIoFeatureToggles(): CustomerIoFeatureToggles
}

View file

@ -60,6 +60,7 @@ import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.operations.attestation.api.TangemApiServiceSettings
import com.tangem.tap.common.analytics.AnalyticsFactory
import com.tangem.tap.common.analytics.CustomerIoFeatureToggles
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler
@ -235,6 +236,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val appsFlyerClientFactory: AppsFlyerClient.Factory
get() = entryPoint.getAppsFlyerClientFactory()
private val customerIoFeatureToggles: CustomerIoFeatureToggles
get() = entryPoint.getCustomerIoFeatureToggles()
// endregion
private val appScope = MainScope()
@ -406,7 +410,10 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
factory.addHandlerBuilder(AmplitudeAnalyticsHandler.Builder())
factory.addHandlerBuilder(FirebaseAnalyticsHandler.Builder())
factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder(appsFlyerClientFactory))
factory.addHandlerBuilder(CustomerIoAnalyticsHandler.Builder())
if (customerIoFeatureToggles.isFeatureEnabled) {
factory.addHandlerBuilder(CustomerIoAnalyticsHandler.Builder())
}
factory.addFilter(oneTimeEventFilter)
factory.addFilter(AppsFlyerEventFilter())

View file

@ -0,0 +1,12 @@
package com.tangem.tap.common.analytics
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import javax.inject.Inject
class CustomerIoFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) {
val isFeatureEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "CUSTOMER_IO_ENABLED")
}

View file

@ -4,6 +4,7 @@ import android.app.Application
import io.customer.messagingpush.ModuleMessagingPushFCM
import io.customer.sdk.CustomerIO
import io.customer.sdk.CustomerIOBuilder
import io.customer.sdk.data.model.Region
import timber.log.Timber
/**
@ -25,6 +26,7 @@ internal class CustomerIoClient(
applicationContext = application,
cdpApiKey = cdpApiKey,
)
.region(Region.EU)
.trackApplicationLifecycleEvents(false)
.autoTrackActivityScreens(false)
.addCustomerIOModule(ModuleMessagingPushFCM())

View file

@ -3,12 +3,19 @@ package com.tangem.tap.common.pushes
import android.annotation.SuppressLint
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.tangem.tap.common.analytics.CustomerIoFeatureToggles
import dagger.hilt.android.AndroidEntryPoint
import io.customer.messagingpush.CustomerIOFirebaseMessagingService
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
@SuppressLint("MissingFirebaseInstanceTokenRefresh")
internal class TangemPushNotificationService : FirebaseMessagingService() {
@Inject
lateinit var customerIoFeatureToggles: CustomerIoFeatureToggles
private val pushNotificationDelegate: PushNotificationDelegate by lazy {
PushNotificationDelegate(applicationContext)
}
@ -17,13 +24,17 @@ internal class TangemPushNotificationService : FirebaseMessagingService() {
super.onNewToken(token)
Timber.d("New FCM token received: $token")
CustomerIOFirebaseMessagingService.onNewToken(applicationContext, token)
if (customerIoFeatureToggles.isFeatureEnabled) {
CustomerIOFirebaseMessagingService.onNewToken(applicationContext, token)
}
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
CustomerIOFirebaseMessagingService.onMessageReceived(applicationContext, message)
if (customerIoFeatureToggles.isFeatureEnabled) {
CustomerIOFirebaseMessagingService.onMessageReceived(applicationContext, message)
}
val notification = message.notification ?: return
val channelId = notification.channelId ?: TANGEM_CHANNEL_ID

View file

@ -168,7 +168,7 @@ internal class DefaultCardSdkProvider @Inject constructor(
secureStorage = secureStorage,
authenticationManager = authenticationManager,
keystoreManager = keystoreManager,
wordlist = Wordlist.getWordlist(activity),
wordlist = Wordlist.getWordlist(),
config = config.apply {
val apiConfig = apiConfigsManager.getEnvironmentConfig(id = ApiConfig.ID.TangemTech)
tangemApiBaseUrl = apiConfig.baseUrl

View file

@ -62,23 +62,19 @@ internal class DefaultTangemPayStorage @Inject constructor(
}
override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) {
withContext(dispatcherProvider.io) {
appPreferencesStore
.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), customerWalletAddress)
}
appPreferencesStore
.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), customerWalletAddress)
}
override suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? {
return withContext(dispatcherProvider.io) {
appPreferencesStore.getSyncOrNull(key = PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId))
.takeIf { !it.isNullOrEmpty() }
}
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId),
)
.takeIf { !it.isNullOrEmpty() }
}
override suspend fun clearCustomerWalletAddress(userWalletId: UserWalletId) {
withContext(dispatcherProvider.io) {
appPreferencesStore.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), "")
}
appPreferencesStore.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), "")
}
override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens) =
@ -92,58 +88,57 @@ internal class DefaultTangemPayStorage @Inject constructor(
}
override suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens? {
val authTokens = secureStorage.get(createAuthTokensKey(customerWalletAddress))
?.decodeToString(throwOnInvalidSequence = true)
?.let(tokensAdapter::fromJson)
return withContext(dispatcherProvider.io) {
val authTokens = secureStorage.get(createAuthTokensKey(customerWalletAddress))
?.decodeToString(throwOnInvalidSequence = true)
?.let(tokensAdapter::fromJson)
return authTokens?.let { tokens ->
if (tokens.idempotencyKey == null) {
val newAuthTokens = tokens.copy(idempotencyKey = UUID.randomUUID().toString())
storeAuthTokens(customerWalletAddress, newAuthTokens)
newAuthTokens
} else {
tokens
authTokens?.let { tokens ->
if (tokens.idempotencyKey == null) {
val newAuthTokens = tokens.copy(idempotencyKey = UUID.randomUUID().toString())
storeAuthTokens(customerWalletAddress, newAuthTokens)
newAuthTokens
} else {
tokens
}
}
}
}
override suspend fun clearAuthTokens(customerWalletAddress: String) {
secureStorage.delete(createAuthTokensKey(customerWalletAddress))
withContext(dispatcherProvider.io) {
secureStorage.delete(createAuthTokensKey(customerWalletAddress))
}
}
override suspend fun storeOrderId(customerWalletAddress: String, orderId: String) {
withContext(dispatcherProvider.io) {
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), orderId)
}
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), orderId)
}
override suspend fun getOrderId(customerWalletAddress: String): String? {
return withContext(dispatcherProvider.io) {
appPreferencesStore.getSyncOrNull(key = PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress))
.takeIf { !it.isNullOrEmpty() }
}
return appPreferencesStore.getSyncOrNull(key = PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress))
.takeIf { !it.isNullOrEmpty() }
}
override suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean {
return withContext(dispatcherProvider.io) {
appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress),
) == true
}
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress),
) == true
}
override suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean) {
withContext(dispatcherProvider.io) {
appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), isDone)
}
appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), isDone)
}
override suspend fun clearOrderId(customerWalletAddress: String) = withContext(dispatcherProvider.io) {
override suspend fun clearOrderId(customerWalletAddress: String) {
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "")
}
override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) {
appPreferencesStore.store(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId), isPaeraCustomer)
appPreferencesStore.store(
PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId),
isPaeraCustomer,
)
}
override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? {
@ -152,7 +147,9 @@ internal class DefaultTangemPayStorage @Inject constructor(
override suspend fun storeActiveWithdrawOrderId(userWalletId: UserWalletId, orderId: String) {
appPreferencesStore.editData { mutablePreferences ->
val orders = mutablePreferences.getObjectMap<String>(PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY)
val orders = mutablePreferences.getObjectMap<String>(
PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
)
.plus(createWithdrawOrderIdKey(userWalletId) to orderId)
mutablePreferences.setObjectMap(
key = PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
@ -179,7 +176,9 @@ internal class DefaultTangemPayStorage @Inject constructor(
}
override suspend fun getActiveWithdrawOrderId(userWalletId: UserWalletId): String? {
val orders = appPreferencesStore.getObjectMapSync<String>(PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY)
val orders = appPreferencesStore.getObjectMapSync<String>(
PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
)
return orders[createWithdrawOrderIdKey(userWalletId)]
}
@ -191,7 +190,9 @@ internal class DefaultTangemPayStorage @Inject constructor(
override suspend fun deleteActiveWithdrawOrder(userWalletId: UserWalletId) {
appPreferencesStore.editData { mutablePreferences ->
val orders = mutablePreferences.getObjectMap<String>(PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY)
val orders = mutablePreferences.getObjectMap<String>(
PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
)
.minus(createWithdrawOrderIdKey(userWalletId))
mutablePreferences.setObjectMap(
key = PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
@ -221,40 +222,33 @@ internal class DefaultTangemPayStorage @Inject constructor(
}
override suspend fun storeHideOnboardingBanner(userWalletId: UserWalletId, hide: Boolean) {
withContext(dispatcherProvider.io) {
appPreferencesStore.store(PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId), hide)
}
appPreferencesStore.store(PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId), hide)
}
override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean {
return withContext(dispatcherProvider.io) {
appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId),
) == true
}
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId),
) == true
}
override suspend fun storeTangemPayEligibility(eligibility: Boolean) {
withContext(dispatcherProvider.io) {
appPreferencesStore.store(key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY, value = eligibility)
}
appPreferencesStore.store(key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY, value = eligibility)
}
override suspend fun getTangemPayEligibility(): Boolean {
return withContext(dispatcherProvider.io) {
appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY, default = false)
}
return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY, default = false)
}
override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) =
override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) {
withContext(dispatcherProvider.io) {
secureStorage.delete(createAuthTokensKey(customerWalletAddress))
appPreferencesStore.store(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId), false)
appPreferencesStore.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), "")
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "")
appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), false)
appPreferencesStore.store(PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId), false)
}
appPreferencesStore.store(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId), false)
appPreferencesStore.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), "")
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "")
appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), false)
appPreferencesStore.store(PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId), false)
}
private fun createAuthTokensKey(address: String): String = "${AUTH_TOKENS_DEFAULT_KEY}_$address"

View file

@ -1,31 +1,11 @@
package com.tangem.common
import com.tangem.utils.SupportedLanguages.CHINESE
import com.tangem.utils.SupportedLanguages.ENGLISH
import com.tangem.utils.SupportedLanguages.FRANCH
import com.tangem.utils.SupportedLanguages.GERMAN
import com.tangem.utils.SupportedLanguages.JAPANESE
import java.util.Locale
object TangemSiteShareUrlBuilder {
private const val BASE_URL = "https://tangem.com"
private const val CRYPTOCURRENCIES_PATH = "cryptocurrencies"
@Deprecated("Should use CHINESE from SupportedLanguages, but the site expects zh-Hans in the URL path")
private const val CHINESE_SITE_LOCALE = "zh-Hans"
@Deprecated("Should rely on SupportedLanguages instead of maintaining a separate list")
private val siteLocales = mapOf(
ENGLISH to ENGLISH,
FRANCH to FRANCH,
GERMAN to GERMAN,
JAPANESE to JAPANESE,
CHINESE to CHINESE_SITE_LOCALE,
)
fun shareUrl(tokenId: String): String {
val locale = siteLocales[Locale.getDefault().language] ?: ENGLISH
return "$BASE_URL/$locale/$CRYPTOCURRENCIES_PATH/$tokenId"
return "$BASE_URL/$CRYPTOCURRENCIES_PATH/$tokenId"
}
}

View file

@ -34,11 +34,11 @@
},
{
"name": "EARN_BLOCK_ENABLED",
"version": "undefined"
"version": "5.35"
},
{
"name": "HOLD_TO_CONFIRM_BUTTON_ENABLED",
"version": "undefined"
"version": "5.35"
},
{
"name": "WALLET_REORDER_FEATURE_ENABLED",
@ -56,6 +56,10 @@
"name": "MULTI_ADDRESS_UTXO_ENABLED",
"version": "undefined"
},
{
"name": "CUSTOMER_IO_ENABLED",
"version": "5.35"
},
{
"name": "MAIN_SCREEN_QR_SCANNING_ENABLED",
"version": "undefined"

View file

@ -3,10 +3,13 @@ package com.tangem.datasource.asset.loader
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.adapter
import com.tangem.utils.coroutines.runCatching
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -25,25 +28,44 @@ import javax.inject.Singleton
class AssetLoader @Inject constructor(
val assetReader: AssetReader,
@NetworkMoshi val moshi: Moshi,
val analyticsExceptionHandler: AnalyticsExceptionHandler,
val dispatchers: CoroutineDispatcherProvider,
) {
/** Load content [Content] of asset file [fileName] */
@Suppress("SuspendFunSwallowedCancellation")
@OptIn(ExperimentalStdlibApi::class)
suspend inline fun <reified Content> load(fileName: String): Content? = runCatching(dispatchers.io) {
val json = assetReader.read(fullFileName = "$fileName.json")
suspend inline fun <reified Content> load(fileName: String): Content? = withContext(dispatchers.io) {
val json = runCatching { assetReader.read(fullFileName = "$fileName.json") }.getOrNull()
moshi.adapter<Content>().fromJson(json)
runCatching {
moshi.adapter<Content>().fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) {
sendException(
fileName = fileName,
isParsingSuccess = true,
json = json,
)
Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
}
parsedConfig
},
onFailure = { throwable ->
sendException(
fileName = fileName,
isParsingSuccess = false,
json = json,
)
Timber.e(throwable, "Failed to load config [$fileName] from assets")
null
},
)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig
},
onFailure = { throwable ->
Timber.e(throwable, "Failed to load config [$fileName] from assets")
null
},
)
/** Load list [V] values of asset file [fileName] */
suspend inline fun <reified V> loadList(fileName: String): List<V> = runCatching(dispatchers.io) {
@ -84,4 +106,18 @@ class AssetLoader @Inject constructor(
emptyMap()
},
)
fun sendException(fileName: String, isParsingSuccess: Boolean, json: String?) {
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = IllegalStateException("Parsing config is failed"),
params = mapOf(
"filename" to fileName,
"isParsingSuccess" to isParsingSuccess.toString(),
"json_size" to json?.length.toString(),
"json" to json?.take(n = 30).toString(),
),
),
)
}
}

View file

@ -1,7 +1,6 @@
package com.tangem.datasource.asset.reader
import android.content.res.AssetManager
import java.io.BufferedReader
/**
* Implementation of asset file reader
@ -13,7 +12,8 @@ internal class AndroidAssetReader(
) : AssetReader {
override suspend fun read(fullFileName: String): String {
return assetManager.open(fullFileName).bufferedReader()
.use(BufferedReader::readText)
return assetManager.open(fullFileName, AssetManager.ACCESS_BUFFER).use { inputStream ->
inputStream.readBytes().toString(Charsets.UTF_8)
}
}
}

View file

@ -5,6 +5,7 @@ import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.adapter
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.datasource.api.express.models.response.Asset
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -23,9 +24,11 @@ class AssetLoaderTest {
private val assetReader = mockk<AssetReader>()
private val moshi = mockk<Moshi>()
private val analyticsExceptionHandler = mockk<AnalyticsExceptionHandler>()
private val assetLoader = AssetLoader(
assetReader = assetReader,
moshi = moshi,
analyticsExceptionHandler = analyticsExceptionHandler,
dispatchers = TestingCoroutineDispatcherProvider(),
)

View file

@ -18,7 +18,7 @@ internal class AndroidAssetReaderTest {
@Test
fun read_content() = runTest {
every { assetManager.open(FILE_NAME) } returns json.byteInputStream()
every { assetManager.open(FILE_NAME, AssetManager.ACCESS_BUFFER) } returns json.byteInputStream()
val actual = assetReader.read(fullFileName = FILE_NAME)
@ -28,7 +28,7 @@ internal class AndroidAssetReaderTest {
@Test
fun read_error() = runTest {
val exception = IOException("Error")
every { assetManager.open(FILE_NAME) } throws exception
every { assetManager.open(FILE_NAME, AssetManager.ACCESS_BUFFER) } throws exception
runCatching { assetReader.read(fullFileName = FILE_NAME) }
.onSuccess { throw IllegalStateException("Error should be thrown") }

View file

@ -9,7 +9,6 @@ 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.utils.coroutines.AppCoroutineScope
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
@ -23,8 +22,12 @@ 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.coroutines.AppCoroutineScope
import com.tangem.utils.extensions.addHexPrefix
import kotlinx.coroutines.*
import kotlinx.coroutines.Job
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
@ -37,6 +40,9 @@ import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds
private const val TAG = "TangemPaySwapRepository"
private const val MAX_POLLING_ATTEMPTS = 6
private data class PollingKey(val userWalletId: String, val orderId: String)
@Suppress("LongParameterList")
internal class DefaultTangemPayWithdrawRepository @Inject constructor(
@ -50,8 +56,8 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
private val withdrawPollingScope: AppCoroutineScope,
) : TangemPayWithdrawRepository {
private val withdrawPollingJobs = mutableMapOf<String, Job>()
private val withdrawPollingMutex = Mutex()
private val pollingJobs = mutableMapOf<PollingKey, Job>()
private val pollingMutex = Mutex()
override suspend fun withdraw(
userWallet: UserWallet,
@ -122,6 +128,76 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
)
} else {
tangemPayStorage.storeWithdrawOrder(userWalletId = userWallet.walletId, data = storeData)
startPollingForOrder(userWallet, orderId, exchangeData)
}
}
}
private fun startPollingForOrder(
userWallet: UserWallet,
orderId: String,
exchangeData: TangemPayWithdrawExchangeState,
) {
withdrawPollingScope.launch {
startWithdrawOrderPolling(userWallet, orderId, exchangeData)
}
}
private suspend fun startWithdrawOrderPolling(
userWallet: UserWallet,
orderId: String,
exchangeData: TangemPayWithdrawExchangeState,
) {
val key = PollingKey(userWallet.walletId.stringValue, orderId)
pollingMutex.withLock {
if (pollingJobs.containsKey(key)) return@withLock
val pollingJob = withdrawPollingScope.launch {
try {
var attemptCount = 0
while (isActive && attemptCount < MAX_POLLING_ATTEMPTS) {
delay(duration = 3.seconds)
attemptCount++
val result = orderRepository.getOrderData(
userWalletId = userWallet.walletId,
orderId = orderId,
)
val txHash = result.getOrNull()?.withdrawTxHash?.ifEmpty { null }
if (!txHash.isNullOrEmpty()) {
finalizeWithdraw(
userWallet = userWallet,
txHash = txHash,
exchangeData = exchangeData,
orderId = orderId,
)
pollingMutex.withLock { pollingJobs.remove(key) }
return@launch
}
}
if (attemptCount >= MAX_POLLING_ATTEMPTS) {
Timber.tag(TAG).e("Polling stopped after $attemptCount unsuccessful attempts")
tangemPayStorage.deleteWithdrawOrder(userWalletId = userWallet.walletId, orderId = orderId)
pollingMutex.withLock { pollingJobs.remove(key) }
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
Timber.tag(TAG).e(exception)
tangemPayStorage.deleteWithdrawOrder(userWalletId = userWallet.walletId, orderId = orderId)
pollingMutex.withLock { pollingJobs.remove(key) }
}
}
pollingJobs[key] = pollingJob
}
}
private fun stopPolling(userWalletId: String, orderId: String) {
withdrawPollingScope.launch {
pollingMutex.withLock {
val key = PollingKey(userWalletId, orderId)
pollingJobs[key]?.cancel()
pollingJobs.remove(key)
}
}
}
@ -141,7 +217,8 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
txHash = txHash,
payInExtraId = exchangeData.payInExtraId,
).also {
tangemPayStorage.deleteWithdrawOrder(userWallet.walletId, orderId)
tangemPayStorage.deleteWithdrawOrder(userWalletId = userWallet.walletId, orderId = orderId)
stopPolling(userWalletId = userWallet.walletId.stringValue, orderId = orderId)
}
}
@ -158,14 +235,12 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
override suspend fun pollWithdrawOrdersIfNeeds(userWallet: UserWallet) {
tangemPayStorage.getWithdrawOrders(userWalletId = userWallet.walletId)?.forEach { state ->
withdrawPollingScope.launch {
try {
pollWithdrawOrderIfNeeds(userWallet = userWallet, data = state)
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
Timber.tag(TAG).e(exception)
}
try {
pollWithdrawOrderIfNeeds(userWallet = userWallet, data = state)
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
Timber.tag(TAG).e(exception)
}
}
}
@ -190,51 +265,7 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
if (!txHash.isNullOrEmpty()) {
finalizeWithdraw(userWallet = userWallet, txHash = txHash, exchangeData = exchangeData, orderId = orderId)
} else {
startWithdrawOrderPolling(userWallet = userWallet, orderId = orderId, exchangeData = exchangeData)
}
return
}
private suspend fun startWithdrawOrderPolling(
userWallet: UserWallet,
orderId: String,
exchangeData: TangemPayWithdrawExchangeState,
) {
withdrawPollingMutex.withLock {
if (withdrawPollingJobs.containsKey(orderId)) return
val pollingJob = withdrawPollingScope.launch {
try {
while (isActive) {
delay(duration = 5.seconds)
orderRepository.getOrderData(userWalletId = userWallet.walletId, orderId = orderId)
.onRight { order ->
val txHash = order.withdrawTxHash
if (txHash.isNullOrEmpty()) return@onRight
finalizeWithdraw(
userWallet = userWallet,
txHash = txHash,
exchangeData = exchangeData,
orderId = orderId,
)
withdrawPollingMutex.withLock { withdrawPollingJobs.remove(orderId) }
return@launch
}
.onLeft { error ->
Timber.tag(TAG).e("getOrderData error ${error.errorCode}")
withdrawPollingMutex.withLock { withdrawPollingJobs.remove(orderId) }
return@launch
}
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
Timber.tag(TAG).e(exception)
withdrawPollingMutex.withLock { withdrawPollingJobs.remove(orderId) }
}
}
withdrawPollingJobs[orderId] = pollingJob
startPollingForOrder(userWallet, orderId, exchangeData)
}
}

View file

@ -21,7 +21,6 @@ import com.tangem.datasource.di.SdkMoshi
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.WcRequestService
@ -92,6 +91,7 @@ internal object WalletConnectDataModule {
dispatchers: CoroutineDispatcherProvider,
getWallets: GetWalletsUseCase,
wcNetworksConverter: WcNetworksConverter,
multiAccountListSupplier: MultiAccountListSupplier,
analytics: AnalyticsEventHandler,
appScope: AppCoroutineScope,
): DefaultWcSessionsManager {
@ -102,6 +102,7 @@ internal object WalletConnectDataModule {
wcNetworksConverter = wcNetworksConverter,
analytics = analytics,
scope = appScope,
multiAccountListSupplier = multiAccountListSupplier,
)
}
@ -177,12 +178,10 @@ internal object WalletConnectDataModule {
namespaceConverters: Set<@JvmSuppressWildcards WcNamespaceConverter>,
walletManagersFacade: WalletManagersFacade,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
singleAccountSupplier: SingleAccountSupplier,
): WcNetworksConverter = WcNetworksConverter(
namespaceConverters = namespaceConverters,
walletManagersFacade = walletManagersFacade,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
singleAccountSupplier = singleAccountSupplier,
)
@Provides

View file

@ -6,10 +6,16 @@ import arrow.core.right
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.utils.*
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.data.walletconnect.utils.WcNetworksConverter
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.WcSdkSessionConverter
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletconnect.WcAnalyticEvents
import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.WcSessionDTO
@ -29,7 +35,8 @@ import kotlin.coroutines.resume
@Suppress("LongParameterList")
internal class DefaultWcSessionsManager(
private val store: WalletConnectStore,
private val getWallets: GetWalletsUseCase,
getWallets: GetWalletsUseCase,
multiAccountListSupplier: MultiAccountListSupplier,
private val dispatchers: CoroutineDispatcherProvider,
private val wcNetworksConverter: WcNetworksConverter,
private val analytics: AnalyticsEventHandler,
@ -38,18 +45,31 @@ internal class DefaultWcSessionsManager(
private val onSessionDelete = Channel<Wallet.Model.SessionDelete>(capacity = Channel.BUFFERED)
override val sessions: Flow<Map<UserWallet, List<WcSession>>>
get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore }
.transform { pair ->
val (wallets, inStore) = pair
val inSdk: List<Wallet.Model.Session> = WalletKit.getListOfActiveSessions()
val associatedSessions: List<WcSession> = associate(inSdk, inStore, wallets)
val someRemove = removeUnknownSessions(inStore, inSdk, associatedSessions)
if (someRemove) return@transform // ignore emit, wait next one
emit(associatedSessions.groupBy { it.wallet })
}
.distinctUntilChanged()
.flowOn(dispatchers.io)
override val sessions: Flow<Map<UserWallet, List<WcSession>>> = combine(
flow = getWallets(),
flow2 = store.sessions,
flow3 = multiAccountListSupplier.invokeMap(),
transform = ::Triple,
)
.transformLatest { triple ->
val (wallets, inStore, allWalletsAccounts) = triple
val inSdk: List<Wallet.Model.Session> = WalletKit.getListOfActiveSessions()
val associatedSessions: List<WcSession> = associate(
inSdk = inSdk,
inStore = inStore,
wallets = wallets,
allWalletsAccounts = allWalletsAccounts,
)
removeUnknownSessions(inStore, inSdk, associatedSessions)
emit(associatedSessions.groupBy { it.wallet })
}
.distinctUntilChanged()
.flowOn(dispatchers.io)
.shareIn(
scope = scope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 0, replayExpirationMillis = 0),
replay = 1,
)
override fun onWcSdkInit() {
listenOnSessionDelete()
@ -79,6 +99,7 @@ internal class DefaultWcSessionsManager(
inSdk: List<Wallet.Model.Session>,
inStore: Set<WcSessionDTO>,
wallets: List<UserWallet>,
allWalletsAccounts: LinkedHashMap<UserWalletId, AccountList>,
): List<WcSession> {
// if the WcSdk `onSessionSettleResponse` callback arrives late, merge pending approvals with WcSdk sessions
val savedPending = store.pendingApproval.first()
@ -92,7 +113,9 @@ internal class DefaultWcSessionsManager(
val wcSessions = savedPending.plus(inStore).mapNotNull { storeSession ->
val wallet = wallets.find { it.walletId == storeSession.walletId } ?: return@mapNotNull null
val sdkSession = inSdk.find { it.topic == storeSession.topic } ?: return@mapNotNull null
val account = wcNetworksConverter.getAccount(storeSession.accountId) as? Account.CryptoPortfolio
val walletAccounts = allWalletsAccounts[storeSession.walletId] ?: return@mapNotNull null
val account = walletAccounts.accounts
.find { account -> account.accountId == storeSession.accountId } as? Account.CryptoPortfolio
?: return@mapNotNull null
val networks = wcNetworksConverter.findWalletNetworks(wallet, account, sdkSession)
val originUrl = storeSession.url ?: sdkSession.metaData?.url ?: ""

View file

@ -6,10 +6,8 @@ import com.tangem.blockchain.common.address.AddressType
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.common.currency.isCustomCoin
import com.tangem.data.walletconnect.model.CAIP10
import com.tangem.domain.account.producer.SingleAccountProducer
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
@ -28,7 +26,6 @@ internal class WcNetworksConverter @Inject constructor(
private val namespaceConverters: Set<WcNamespaceConverter>,
private val walletManagersFacade: WalletManagersFacade,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val singleAccountSupplier: SingleAccountSupplier,
) {
fun createNetwork(chainId: String, wallet: UserWallet): Network? {
@ -117,10 +114,6 @@ internal class WcNetworksConverter @Inject constructor(
return existNetworks
}
suspend fun getAccount(accountId: AccountId): Account? {
return singleAccountSupplier.getSyncOrNull(SingleAccountProducer.Params(accountId))
}
suspend fun convertNetworksForApprove(sessionForApprove: WcSessionApprove): List<Network> {
val portfolioNetworks = getAccountNetworks(sessionForApprove.account.accountId)
return sessionForApprove.network

View file

@ -3,7 +3,9 @@ package com.tangem.domain.account.supplier
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.MultiAccountListProducer
import com.tangem.domain.core.flow.FlowCachingSupplier
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Supplier that provides a list of [AccountList]s for all user wallets.
@ -18,4 +20,12 @@ abstract class MultiAccountListSupplier(
operator fun invoke(): Flow<List<AccountList>> {
return super.invoke(params = Unit)
}
fun invokeMap(): Flow<LinkedHashMap<UserWalletId, AccountList>> = invoke()
.map { accountLists ->
accountLists.associateByTo(
destination = linkedMapOf(),
keySelector = { accountList -> accountList.userWalletId },
)
}
}

View file

@ -191,4 +191,14 @@ sealed class TangemPayAnalyticsEvents(
categoryName = "Visa Onboarding",
event = "Visa KYC Canceled",
)
class MainVisaPermanentBannerClicked : TangemPayAnalyticsEvents(
categoryName = "Visa Onboarding",
event = "Visa Permanent Banner Clicked",
)
class DetailsVisaPermanentButtonClicked : TangemPayAnalyticsEvents(
categoryName = "Visa Onboarding",
event = "Visa Permanent Button Clicked",
)
}

View file

@ -23,6 +23,7 @@ import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
@ -264,6 +265,7 @@ internal class DetailsModel @Inject constructor(
private fun onTangemPayItemClicked() {
modelScope.launch {
analyticsEventHandler.send(TangemPayAnalyticsEvents.DetailsVisaPermanentButtonClicked())
val isEligible = tangemPayEligibilityManager.getTangemPayAvailability()
if (isEligible) {
router.push(AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.FromBannerInSettings))

View file

@ -1,6 +1,5 @@
package com.tangem.features.hotwallet.common.repository
import android.content.Context
import com.tangem.crypto.bip39.DefaultMnemonic
import com.tangem.crypto.bip39.EntropyLength
import com.tangem.crypto.bip39.Mnemonic
@ -8,15 +7,13 @@ import com.tangem.crypto.bip39.Wordlist
import com.tangem.features.hotwallet.MnemonicRepository
import com.tangem.features.hotwallet.MnemonicRepository.MnemonicType
import com.tangem.sdk.extensions.getWordlist
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
internal class DefaultMnemonicRepository @Inject constructor(
@ApplicationContext private val context: Context,
) : MnemonicRepository {
private val wordlist = Wordlist.getWordlist(context)
internal class DefaultMnemonicRepository @Inject constructor() : MnemonicRepository {
override val words: Set<String> = wordlist.words.toHashSet()
private val wordlist by lazy(LazyThreadSafetyMode.NONE) { Wordlist.getWordlist() }
override val words: Set<String> by lazy(LazyThreadSafetyMode.NONE) { wordlist.words.toHashSet() }
override fun generateMnemonic(type: MnemonicType): Mnemonic = DefaultMnemonic(
entropy = when (type) {

View file

@ -59,7 +59,7 @@ internal fun MultiWalletAccessCodeEnter(
val focusRequester = remember { FocusRequester() }
OutlineTextField(
modifier = modifier
modifier = Modifier
.focusRequester(focusRequester)
.fillMaxWidth(),
value = if (reEnterAccessCodeState) {
@ -73,12 +73,14 @@ internal fun MultiWalletAccessCodeEnter(
state.onAccessCodeFirstChange
},
label = stringResourceSafe(id = R.string.onboarding_wallet_info_title_third),
isError = state.codesNotMatchError,
isError = state.codesNotMatchError || state.atLeast4CharError,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
caption = when {
state.codesNotMatchError && reEnterAccessCodeState ->
stringResourceSafe(R.string.onboarding_access_codes_doesnt_match)
state.atLeast4CharError && !reEnterAccessCodeState ->
stringResourceSafe(R.string.onboarding_access_code_too_short)
else -> null
},
)

View file

@ -125,7 +125,6 @@ internal class TangemPayDetailsModel @Inject constructor(
modelScope.launch {
expressTransactionsEventListener.send(ExpressTransactionsEvent.Update)
}
subscribeToWithdrawOrder()
}
fun onPause() {
@ -150,14 +149,6 @@ internal class TangemPayDetailsModel @Inject constructor(
.launchIn(modelScope)
}
private fun subscribeToWithdrawOrder() {
modelScope.launch {
val userWallet = userWallet ?: getUserWalletUseCase(params.userWalletId).getOrNull()
?: return@launch
tangemPayWithdrawRepository.pollWithdrawOrdersIfNeeds(userWallet)
}
}
override fun onClickPinCode() {
analytics.send(TangemPayAnalyticsEvents.PinCodeClicked())
if (!params.config.isPinSet) {

View file

@ -737,15 +737,20 @@ internal class TokenDetailsModel @Inject constructor(
analyticsEventsHandler.send(TokenScreenAnalyticsEvent.ButtonRemoveToken(cryptoCurrency.symbol))
modelScope.launch {
val canHide = cryptoCurrency is CryptoCurrency.Coin && isCryptoCurrencyCoinCouldHideUseCase(
userWalletId = userWalletId,
cryptoCurrencyCoin = cryptoCurrency,
)
val canHide = when (cryptoCurrency) {
is CryptoCurrency.Coin -> {
isCryptoCurrencyCoinCouldHideUseCase(
userWalletId = userWalletId,
cryptoCurrencyCoin = cryptoCurrency,
)
}
is CryptoCurrency.Token -> true
}
internalUiState.value = if (!canHide) {
stateFactory.getStateWithLinkedTokensDialog(cryptoCurrency)
} else {
internalUiState.value = if (canHide) {
stateFactory.getStateWithConfirmHideTokenDialog(cryptoCurrency)
} else {
stateFactory.getStateWithLinkedTokensDialog(cryptoCurrency)
}
}
}

View file

@ -5,7 +5,6 @@ import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem
@ -70,7 +69,7 @@ internal class AccountTokenItemConverter(
tokenItemState = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = accountStatus.account,
).convert(TotalFiatBalance.Loading),
).convert(accountStatus.tokenList.totalFiatBalance),
),
)
if (isGrouping) {

View file

@ -245,6 +245,7 @@ internal class TangemPayClickIntentsImplementor @Inject constructor(
override fun onOnboardingBannerClick(userWalletId: UserWalletId) {
modelScope.launch {
analyticsEventHandler.send(TangemPayAnalyticsEvents.MainVisaPermanentBannerClicked())
val isEligible = tangemPayEligibilityManager.getTangemPayAvailability()
if (isEligible) {
router.openTangemPayOnboarding(mode = AppRoute.TangemPayOnboarding.Mode.FromBannerOnMain)

View file

@ -1,5 +1,6 @@
package com.tangem.feature.wallet.child.wallet.model.intents
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.address.AddressType
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
@ -292,7 +293,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
)
},
ifRight = {
stateHolder.update(CloseBottomSheetTransformer(userWalletId = accountId.userWalletId))
router.dialogNavigation.dismiss()
},
)
}

View file

@ -138,7 +138,7 @@ internal object WalletScreenPreviewDataLegacy {
TokensListItemUM.Portfolio(
content = PortfolioItemContentUM.Empty(
action = PortfolioItemContentUM.Empty.Action(
text = stringReference("Manage tokens"),
text = resourceReference(id = R.string.onboarding_add_tokens),
onClick = {},
),
),

View file

@ -1,14 +1,15 @@
package com.tangem.feature.wallet.presentation.wallet.domain
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.network.Network
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.walletmanager.WalletManagersFacade
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
@ModelScoped
@ -27,18 +28,23 @@ class HasSingleWalletSignedHashesUseCase @Inject constructor(
return@map false
}
return@map walletManagersFacade.validateSignatureCount(
userWalletId = userWallet.walletId,
network = network,
signedHashes = userWallet.scanResponse.card.wallets.firstOrNull()?.totalSignedHashes ?: 0,
)
.fold(
ifLeft = { true },
ifRight = {
cardRepository.setCardWasScanned(cardId = userWallet.cardId)
false
},
return@map try {
walletManagersFacade.validateSignatureCount(
userWalletId = userWallet.walletId,
network = network,
signedHashes = userWallet.scanResponse.card.wallets.firstOrNull()?.totalSignedHashes ?: 0,
)
.fold(
ifLeft = { true },
ifRight = {
cardRepository.setCardWasScanned(cardId = userWallet.cardId)
false
},
)
} catch (e: IllegalArgumentException) {
Timber.w(e, "Unable to validate signature count: user wallet not found")
false
}
}
}

View file

@ -17,6 +17,11 @@ internal class DeleteWalletTransformer(
val deletedWalletUM = prevState.getDeletedWalletState2()
return when {
deletedWalletUM != null && deletedWalletState != null -> prevState.copy(
selectedWalletIndex = selectedWalletIndex,
wallets = (prevState.wallets - deletedWalletState).toImmutableList(),
wallets2 = (prevState.wallets2 - deletedWalletUM).toImmutableList(),
)
deletedWalletUM != null -> prevState.copy(
selectedWalletIndex = selectedWalletIndex,
wallets2 = (prevState.wallets2 - deletedWalletUM).toImmutableList(),

View file

@ -142,7 +142,7 @@ internal class TokenListStateConverter(
is WalletTokensListState.Empty -> listOf()
}
val onEmptyAction = PortfolioItemContentUM.Empty.Action(
text = resourceReference(id = R.string.main_manage_tokens),
text = resourceReference(id = R.string.onboarding_add_tokens),
onClick = { clickIntents.onManageTokensClick(account.accountId) },
)
return TokensListPortfolioItemConverter(

View file

@ -6,6 +6,7 @@ import com.tangem.domain.pay.model.MainCustomerInfoContentState
import com.tangem.domain.pay.model.MainScreenCustomerInfo
import com.tangem.domain.pay.model.TangemPayCustomerInfoError
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
@ -19,6 +20,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
@Suppress("LongParameterList")
@ -28,10 +30,14 @@ internal class TangemPayMainSubscriber @AssistedInject constructor(
private val clickIntents: WalletClickIntents,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val tangemPayMainScreenCustomerInfoUseCase: TangemPayMainScreenCustomerInfoUseCase,
private val tangemPayWithdrawRepository: TangemPayWithdrawRepository,
private val analytics: WalletTangemPayAnalyticsEventSender,
) : WalletSubscriber() {
override fun create(coroutineScope: CoroutineScope): Flow<*> {
coroutineScope.launch {
tangemPayWithdrawRepository.pollWithdrawOrdersIfNeeds(userWallet)
}
return subscribeOnTangemPayInfoUpdates()
}

View file

@ -11,7 +11,7 @@ tangemCardSdk = "develop-582"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
tangemVico = "2.0.0-alpha.25-tangem12"
#tangemVico = "0.0.1" # Keep it! - used for local builds ^
tangemHotSdk = "develop-539"
tangemHotSdk = "develop-547"
#tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^

View file

@ -1,5 +1,6 @@
package com.tangem.blockchainsdk.providers
import android.os.Build
import com.tangem.blockchainsdk.BlockchainProvidersResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.config.providers.BlockchainProvidersStorage
@ -28,7 +29,21 @@ internal class BlockchainProvidersResponseLoader @Inject constructor(
/** Load [BlockchainProvidersResponse] */
suspend fun load(): BlockchainProvidersResponse? {
val localResponse = loadLocal().ifEmpty { return null }
val localResponse = loadLocal()
/**
* Behavior when local config is empty or cannot be parsed:
* - Android 15 -> return null (do not load remote)
* - Android 16 (stable) -> return null (do not load remote)
* - Android 16 (preview)-> continue (attempt to load remote and merge)
* - Android 17+ -> continue (attempt to load remote and merge)
*/
val shouldLoadRemoteIfError = Build.VERSION.SDK_INT > ANDROID_16_SDK_VERSION ||
Build.VERSION.SDK_INT == ANDROID_16_SDK_VERSION && Build.VERSION.PREVIEW_SDK_INT > 0
if (localResponse.isEmpty() && !shouldLoadRemoteIfError) {
return null
}
return loadRemote().fold(
onSuccess = { remoteResponse ->
@ -39,7 +54,7 @@ internal class BlockchainProvidersResponseLoader @Inject constructor(
},
onFailure = { throwable ->
Timber.e(throwable, "Failed to load blockchain provider types from backend")
localResponse
localResponse.ifEmpty { null }
},
)
}
@ -50,4 +65,9 @@ internal class BlockchainProvidersResponseLoader @Inject constructor(
dispatcher = dispatchers.io,
block = tangemTechApi::getBlockchainProviders,
)
private companion object {
// TODO Replace with Build.VERSION_CODES
const val ANDROID_16_SDK_VERSION = 36
}
}