Updated on 2026-08-14

This commit is contained in:
Tangem 2024-11-21 12:30:21 +03:00
commit defd947695
44 changed files with 654 additions and 227 deletions

View file

@ -109,8 +109,16 @@ private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: Detail
isHidingEnabled = action.hideBalance,
),
)
// state should be copied to avoid concurrent modifications from different sources
is DetailsAction.AppSettings.Prepare -> state.copy(
appSettingsState = action.state,
appSettingsState = state.appSettingsState.copy(
saveWallets = action.state.saveWallets,
saveAccessCodes = action.state.saveAccessCodes,
isBiometricsAvailable = action.state.isBiometricsAvailable,
isHidingEnabled = action.state.isHidingEnabled,
selectedAppCurrency = action.state.selectedAppCurrency,
selectedThemeMode = action.state.selectedThemeMode,
),
)
is DetailsAction.AppSettings.EnrollBiometrics,
is DetailsAction.AppSettings.CheckBiometricsStatus,

View file

@ -4,6 +4,7 @@ import android.net.Uri
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.ifNotNull
import com.tangem.common.extensions.toHexString
@ -227,7 +228,7 @@ private fun navigateToWalletScreen() {
}
}
private suspend fun readCard(onSuccess: suspend (ScanResponse) -> Unit) {
private suspend fun readCard(onSuccess: suspend (ScanResponse) -> Unit, onFailure: (TangemError) -> Unit) {
val shouldSaveAccessCodes = store.inject(DaggerGraphState::settingsRepository).shouldSaveAccessCodes()
store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy(
@ -248,6 +249,7 @@ private suspend fun readCard(onSuccess: suspend (ScanResponse) -> Unit) {
Timber.e(it, "Unable to scan card")
delay(HIDE_PROGRESS_DELAY)
store.dispatch(HomeAction.ScanInProgress(scanInProgress = false))
onFailure(it)
},
onSuccess = onSuccess,
)
@ -606,9 +608,14 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
}
if (scanResponse == null) {
scope.launch {
readCard { newScanResponse ->
handleFinishBackup(newScanResponse)
}
readCard(
onSuccess = { newScanResponse ->
handleFinishBackup(newScanResponse)
},
onFailure = {
store.dispatchNavigationAction(AppRouter::pop)
},
)
}
} else {
handleFinishBackup(scanResponse)
@ -644,10 +651,15 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
} else {
delay(HIDE_PROGRESS_DELAY)
readCard { newScanResponse ->
scanResponse = newScanResponse
userWallet = createUserWallet(newScanResponse, backupState)
}
readCard(
onSuccess = { newScanResponse ->
scanResponse = newScanResponse
userWallet = createUserWallet(newScanResponse, backupState)
},
onFailure = {
store.dispatchNavigationAction(AppRouter::pop)
},
)
}
val notActivatedCardIds = gatherCardIds(backupState, card).mapNotNull {

View file

@ -2,15 +2,15 @@ package com.tangem.tap.network.auth
import com.tangem.common.extensions.toHexString
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
internal class DefaultAuthProvider(private val appStateHolder: AppStateHolder) : AuthProvider {
internal class DefaultAuthProvider(private val userWalletsListManager: UserWalletsListManager) : AuthProvider {
override fun getCardPublicKey(): String {
return appStateHolder.scanResponse?.card?.cardPublicKey?.toHexString() ?: ""
return userWalletsListManager.selectedUserWalletSync?.scanResponse?.card?.cardPublicKey?.toHexString() ?: ""
}
override fun getCardId(): String {
return appStateHolder.scanResponse?.card?.cardId ?: ""
return userWalletsListManager.selectedUserWalletSync?.scanResponse?.card?.cardId ?: ""
}
}

View file

@ -4,13 +4,13 @@ import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider
import com.tangem.tap.network.auth.DefaultAppVersionProvider
import com.tangem.tap.network.auth.DefaultAuthProvider
import com.tangem.tap.network.auth.DefaultExpressAuthProvider
import com.tangem.tap.network.auth.DefaultStakeKitAuthProvider
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.utils.version.AppVersionProvider
import dagger.Module
import dagger.Provides
@ -24,8 +24,8 @@ class AuthModule {
@Provides
@Singleton
fun provideAuthProvider(appStateHolder: AppStateHolder): AuthProvider {
return DefaultAuthProvider(appStateHolder)
fun provideAuthProvider(userWalletsListManager: UserWalletsListManager): AuthProvider {
return DefaultAuthProvider(userWalletsListManager)
}
@Provides

View file

@ -27,6 +27,10 @@ sealed class GiveTxPermissionState {
val cancelButton: CancelPermissionButton,
val onChangeApproveType: ((ApproveType) -> Unit)? = null,
) : GiveTxPermissionState()
fun GiveTxPermissionState.getApproveTypeOrNull(): ApproveType? {
return (this as? ReadyForRequest)?.approveType
}
}
enum class ApproveType(val text: TextReference) {

View file

@ -1,11 +1,13 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.utils.RequestHeader
import com.tangem.utils.version.AppVersionProvider
/** TangemTech [ApiConfig] */
internal class TangemTech(
private val appVersionProvider: AppVersionProvider,
private val authProvider: AuthProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD
@ -29,5 +31,6 @@ internal class TangemTech(
private fun createHeaders() = buildMap {
putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider).values)
putAll(from = RequestHeader.AuthenticationHeader(authProvider).values)
}
}

View file

@ -51,19 +51,11 @@ interface TangemTechApi {
/** Returns referral status by [walletId] */
@GET("referral/{walletId}")
suspend fun getReferralStatus(
@Header("card_public_key") cardPublicKey: String,
@Header("card_id") cardId: String,
@Path("walletId") walletId: String,
): ReferralResponse
suspend fun getReferralStatus(@Path("walletId") walletId: String): ReferralResponse
/** Make user referral, requires [StartReferralBody] */
@POST("referral")
suspend fun startReferral(
@Header("card_public_key") cardPublicKey: String,
@Header("card_id") cardId: String,
@Body startReferralBody: StartReferralBody,
): ReferralResponse
suspend fun startReferral(@Body startReferralBody: StartReferralBody): ReferralResponse
@GET("quotes")
suspend fun getQuotes(
@ -79,55 +71,35 @@ interface TangemTechApi {
): ApiResponse<PromotionInfoResponse>
@GET("settings/{wallet_id}")
suspend fun getUserTokensSettings(
@Header("card_public_key") cardPublicKey: String,
@Header("card_id") cardId: String,
@Path("wallet_id") walletId: String,
): ApiResponse<UserTokensSettingsResponse>
suspend fun getUserTokensSettings(@Path("wallet_id") walletId: String): ApiResponse<UserTokensSettingsResponse>
@PUT("settings/{wallet_id}")
suspend fun saveUserTokensSettings(
@Header("card_public_key") cardPublicKey: String,
@Header("card_id") cardId: String,
@Path("wallet_id") walletId: String,
@Body userTokensSettings: UserTokensSettingsResponse,
): ApiResponse<Unit>
@POST("user-network-account")
suspend fun createUserNetworkAccount(
@Header("card_public_key") cardPublicKey: String,
@Header("card_id") cardId: String,
@Body body: CreateUserNetworkAccountBody,
): ApiResponse<CreateUserNetworkAccountResponse>
@POST("account")
suspend fun createUserTokensAccount(
@Header("card_public_key") cardPublicKey: String,
@Header("card_id") cardId: String,
@Body body: CreateUserTokensAccountBody,
): ApiResponse<UserTokensAccountResponse>
@PUT("account/{account_id}")
suspend fun updateUserTokensAccount(
@Header("card_public_key") cardPublicKey: String,
@Header("card_id") cardId: String,
@Path("account_id") accountId: Int,
@Body body: UpdateUserTokensAccountBody,
): ApiResponse<UserTokensAccountResponse>
@PUT("account/{account_id}/archive")
suspend fun archiveUserTokensAccount(
@Header("card_public_key") cardPublicKey: String,
@Header("card_id") cardId: String,
@Path("account_id") accountId: Int,
): ApiResponse<UserTokensAccountResponse>
suspend fun archiveUserTokensAccount(@Path("account_id") accountId: Int): ApiResponse<UserTokensAccountResponse>
@PUT("account/{account_id}/unarchive")
suspend fun restoreUserTokensAccount(
@Header("card_public_key") cardPublicKey: String,
@Header("card_id") cardId: String,
@Path("account_id") accountId: Int,
): ApiResponse<UserTokensAccountResponse>
suspend fun restoreUserTokensAccount(@Path("account_id") accountId: Int): ApiResponse<UserTokensAccountResponse>
@GET("features")
suspend fun getFeatures(): ApiResponse<FeaturesResponse>

View file

@ -7,16 +7,10 @@ import retrofit2.http.*
interface TangemTechApiV2 {
@GET("user-tokens/{wallet_id}")
suspend fun getUserTokens(
@Header("card_public_key") cardPublicKey: String,
@Header("card_id") cardId: String,
@Path("wallet_id") walletId: String,
): ApiResponse<UserTokensResponseV2>
suspend fun getUserTokens(@Path("wallet_id") walletId: String): ApiResponse<UserTokensResponseV2>
@PUT("user-tokens/{wallet_id}")
suspend fun saveUserTokens(
@Header("card_public_key") cardPublicKey: String,
@Header("card_id") cardId: String,
@Path("wallet_id") walletId: String,
@Body userTokens: UserTokensResponseV2,
): ApiResponse<Unit>

View file

@ -1,5 +1,6 @@
package com.tangem.datasource.di
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.Express
import com.tangem.datasource.api.common.config.StakeKit
@ -36,5 +37,6 @@ internal object ApiConfigsModule {
@Provides
@IntoSet
fun provideTangemTechConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemTech(appVersionProvider)
fun provideTangemTechConfig(appVersionProvider: AppVersionProvider, authProvider: AuthProvider): ApiConfig =
TangemTech(appVersionProvider, authProvider)
}

View file

@ -1,8 +1,12 @@
package com.tangem.datasource.utils
import android.os.Build
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.utils.RequestHeader.CacheControlHeader.checkHeaderValueOrEmpty
import com.tangem.utils.Provider
import com.tangem.utils.version.AppVersionProvider
import java.util.Locale
import java.util.TimeZone
/**
* Presentation of request header
@ -24,5 +28,24 @@ sealed class RequestHeader(vararg pairs: Pair<String, Provider<String>>) {
class AppVersionPlatformHeaders(appVersionProvider: AppVersionProvider) : RequestHeader(
"version" to Provider(appVersionProvider::versionName),
"platform" to Provider { "android" },
"language" to Provider { Locale.getDefault().language.checkHeaderValueOrEmpty() },
"timezone" to Provider {
TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty()
},
"device" to Provider { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() },
)
/**
* Use it to avoid crash in okhttp headers
*/
fun String.checkHeaderValueOrEmpty(): String {
for (i in this.indices) {
val c = this[i]
val charCondition = c == '\t' || c in '\u0020'..'\u007e'
if (!charCondition) {
return ""
}
}
return this
}
}

View file

@ -1,7 +1,9 @@
package com.tangem.datasource.api.common.config.managers
import android.os.Build
import com.google.common.truth.Truth
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.config.*
import com.tangem.datasource.api.common.config.ApiConfig.Companion.DEBUG_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.EXTERNAL_BUILD_TYPE
@ -18,16 +20,19 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import java.util.Locale
import java.util.TimeZone
private val configManager = MockEnvironmentConfigStorage()
private val appVersionProvider = mockk<AppVersionProvider>()
private val expressAuthProvider = mockk<ExpressAuthProvider>()
private val stakeKitAuthProvider = mockk<StakeKitAuthProvider>()
private val appAuthProvider = mockk<AuthProvider>()
// Don't forget to add new config !!!
private val API_CONFIGS = setOf(
Express(configManager, expressAuthProvider, appVersionProvider),
TangemTech(appVersionProvider),
TangemTech(appVersionProvider, appAuthProvider),
StakeKit(stakeKitAuthProvider),
)
@ -46,6 +51,8 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
every { expressAuthProvider.getSessionId() } returns EXPRESS_SESSION_ID
every { expressAuthProvider.getRefCode() } returns EXPRESS_REF_CODE
every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY
every { appAuthProvider.getCardId() } returns APP_CARD_ID
every { appAuthProvider.getCardPublicKey() } returns APP_CARD_PUBLIC_KEY
}
@Test
@ -68,6 +75,8 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
const val EXPRESS_SESSION_ID = "express_session_id"
const val EXPRESS_REF_CODE = "express_ref_code"
const val STAKE_KIT_API_KEY = "stake_kit_api_key"
const val APP_CARD_ID = "app_card_id"
const val APP_CARD_PUBLIC_KEY = "app_public_key"
@JvmStatic
@Parameterized.Parameters
@ -118,6 +127,11 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
"refcode" to Provider { EXPRESS_REF_CODE },
"version" to Provider { VERSION_NAME },
"platform" to Provider { "android" },
"language" to Provider { Locale.getDefault().language.checkHeaderValueOrEmpty() },
"timezone" to Provider {
TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty()
},
"device" to Provider { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() },
),
),
)
@ -130,8 +144,15 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem-tech.com/v1/",
headers = mapOf(
"card_id" to Provider { APP_CARD_ID },
"card_public_key" to Provider { APP_CARD_PUBLIC_KEY },
"version" to Provider { VERSION_NAME },
"platform" to Provider { "android" },
"language" to Provider { Locale.getDefault().language.checkHeaderValueOrEmpty() },
"timezone" to Provider {
TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty()
},
"device" to Provider { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() },
),
),
)
@ -150,5 +171,16 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
),
)
}
private fun String.checkHeaderValueOrEmpty(): String {
for (i in this.indices) {
val c = this[i]
val charCondition = c == '\t' || c in '\u0020'..'\u007e'
if (!charCondition) {
return ""
}
}
return this
}
}
}

View file

@ -0,0 +1,111 @@
package com.tangem.core.ui.components.notifications
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonColors
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemColorPalette.Dark6
import com.tangem.core.ui.res.TangemColorPalette.Light2
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
fun NoteMigrationNotification(config: NotificationConfig, modifier: Modifier = Modifier) {
val button = config.buttonsState as? NotificationConfig.ButtonsState.SecondaryButtonConfig
button ?: return
Column(
modifier = modifier
.fillMaxWidth()
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
.background(TangemColorPalette.Dark6),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
modifier = Modifier.heightIn(max = 146.dp),
painter = painterResource(id = R.drawable.banner_note_migration),
contentDescription = null,
contentScale = ContentScale.Fit,
)
Column(
modifier = Modifier.padding(
start = 12.dp,
end = 12.dp,
bottom = 12.dp,
),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
config.title?.let {
Text(
text = it.resolveReference(),
color = TangemColorPalette.White,
style = TangemTheme.typography.h3,
)
}
Text(
text = config.subtitle.resolveReference(),
textAlign = TextAlign.Center,
color = TangemColorPalette.Light5,
style = TangemTheme.typography.body2,
)
TangemButton(
modifier = Modifier.fillMaxWidth(),
text = button.text.resolveReference(),
icon = TangemButtonIconPosition.None,
onClick = button.onClick,
colors = TangemButtonColors(
backgroundColor = Light2,
contentColor = Dark6,
disabledBackgroundColor = TangemTheme.colors.button.disabled,
disabledContentColor = TangemTheme.colors.text.disabled,
),
enabled = true,
textStyle = TangemTheme.typography.subtitle1,
showProgress = false,
)
}
}
}
// region Preview
@Preview(showBackground = true, widthDp = 328)
@Composable
private fun NoteMigrationNotification_Preview() {
TangemThemePreview {
NoteMigrationNotification(
NotificationConfig(
title = stringReference("Discover Tangem Wallet"),
subtitle = stringReference(
"Access 13,000+ cryptocurrencies. Buy, sell, swap, and stake with a single tap. Link up to " +
"three cards for a backup.",
),
iconResId = R.drawable.ic_empty_64,
onCloseClick = { },
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = stringReference("Get now"),
onClick = {},
),
),
)
}
}
// endregion

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

View file

@ -70,14 +70,10 @@ internal class DefaultMarketsTokenRepository(
// we shouldn't infinitely retry on the first batch request
val res = if (isFirstBatchFetching) {
catchApiErrorAndSendEvent(errorEvent = MarketsDataAnalyticsEvent.List.Error) {
requestCall()
}
catchListErrorAndSendEvent { requestCall() }
} else {
retryOnError(priority = true) {
catchApiErrorAndSendEvent(errorEvent = MarketsDataAnalyticsEvent.List.Error) {
requestCall()
}
catchListErrorAndSendEvent { requestCall() }
}
}
@ -106,7 +102,7 @@ internal class DefaultMarketsTokenRepository(
marketsApi = marketsApi,
analyticsEventHandler = analyticsEventHandler,
onApiError = {
analyticsEventHandler.send(MarketsDataAnalyticsEvent.List.Error.toEvent())
analyticsEventHandler.send(createListErrorEvent(it).toEvent())
},
)
@ -134,11 +130,9 @@ internal class DefaultMarketsTokenRepository(
interval = interval.toRequestParam(),
)
val result = catchApiErrorAndSendEvent(
errorEvent = MarketsDataAnalyticsEvent.Details.Error(
request = MarketsDataAnalyticsEvent.Details.Error.Request.Chart,
tokenSymbol = tokenSymbol,
),
val result = catchDetailsErrorAndSendEvent(
request = MarketsDataAnalyticsEvent.Details.Error.Request.Chart,
tokenSymbol = tokenSymbol,
) {
response.getOrThrow()
}
@ -152,6 +146,7 @@ internal class DefaultMarketsTokenRepository(
analyticsEventHandler.send(
MarketsDataAnalyticsEvent.ChartNullValuesError(
requestPath = "coins/history",
errorType = MarketsDataAnalyticsEvent.Type.Custom,
),
)
},
@ -165,18 +160,16 @@ internal class DefaultMarketsTokenRepository(
tokenSymbol: String,
) = withContext(dispatcherProvider.io) {
val mappedTokenId = getTokenIdIfL2Network(tokenId)
val response = marketsApi.getCoinsListCharts(
coinIds = mappedTokenId,
currency = fiatCurrencyCode,
interval = interval.toRequestParam(),
)
val chart = catchApiErrorAndSendEvent(errorEvent = MarketsDataAnalyticsEvent.List.Error) {
response.getOrThrow()[mappedTokenId] ?: error(
val chart = catchListErrorAndSendEvent {
marketsApi.getCoinsListCharts(
coinIds = mappedTokenId,
currency = fiatCurrencyCode,
interval = interval.toRequestParam(),
).getOrThrow()[mappedTokenId] ?: error(
"No chart preview data for the token $mappedTokenId",
)
}
return@withContext TokenChartConverter.convert(
interval = interval,
value = chart,
@ -186,6 +179,7 @@ internal class DefaultMarketsTokenRepository(
analyticsEventHandler.send(
MarketsDataAnalyticsEvent.ChartNullValuesError(
requestPath = "coins/history_preview",
errorType = MarketsDataAnalyticsEvent.Type.Custom,
),
)
},
@ -198,19 +192,15 @@ internal class DefaultMarketsTokenRepository(
tokenSymbol: String,
languageCode: String,
) = withContext(dispatcherProvider.io) {
val response = marketsApi.getCoinMarketData(
currency = fiatCurrencyCode,
coinId = tokenId,
language = languageCode,
)
val result = catchApiErrorAndSendEvent(
errorEvent = MarketsDataAnalyticsEvent.Details.Error(
request = MarketsDataAnalyticsEvent.Details.Error.Request.Info,
tokenSymbol = tokenSymbol,
),
val result = catchDetailsErrorAndSendEvent(
request = MarketsDataAnalyticsEvent.Details.Error.Request.Info,
tokenSymbol = tokenSymbol,
) {
response.getOrThrow()
marketsApi.getCoinMarketData(
currency = fiatCurrencyCode,
coinId = tokenId,
language = languageCode,
).getOrThrow()
}
val resultResponse = result.applyL2Compatibility(tokenId)
@ -220,19 +210,16 @@ internal class DefaultMarketsTokenRepository(
override suspend fun getTokenQuotes(fiatCurrencyCode: String, tokenId: String, tokenSymbol: String) =
withContext(dispatcherProvider.io) {
// for second markets iteration we should use extended api method with all required fields
val response = tangemTechApi.getQuotes(
currencyId = fiatCurrencyCode,
coinIds = tokenId,
fields = marketsQuoteFields.joinToString(separator = ","),
)
val result = catchApiErrorAndSendEvent(
errorEvent = MarketsDataAnalyticsEvent.Details.Error(
request = MarketsDataAnalyticsEvent.Details.Error.Request.Info,
tokenSymbol = tokenSymbol,
),
val result = catchDetailsErrorAndSendEvent(
request = MarketsDataAnalyticsEvent.Details.Error.Request.Info,
tokenSymbol = tokenSymbol,
) {
response.getOrThrow()
tangemTechApi.getQuotes(
currencyId = fiatCurrencyCode,
coinIds = tokenId,
fields = marketsQuoteFields.joinToString(separator = ","),
).getOrThrow()
}
return@withContext TokenQuotesShortConverter.convert(tokenId, result).toFull()
@ -282,15 +269,75 @@ internal class DefaultMarketsTokenRepository(
}
}
private inline fun <T> catchApiErrorAndSendEvent(errorEvent: MarketsDataAnalyticsEvent, block: () -> T): T {
inline fun <T> catchListErrorAndSendEvent(block: () -> T): T {
return catchErrorAndSendEvent(block, ::createListErrorEvent)
}
private inline fun <T> catchDetailsErrorAndSendEvent(
request: MarketsDataAnalyticsEvent.Details.Error.Request,
tokenSymbol: String,
block: () -> T,
): T {
return catchErrorAndSendEvent(block) { error ->
createDetailsErrorEvent(error, request, tokenSymbol)
}
}
private inline fun <T> catchErrorAndSendEvent(
block: () -> T,
createErrorEvent: (ApiResponseError) -> MarketsDataAnalyticsEvent,
): T {
return try {
block()
} catch (e: ApiResponseError.HttpException) {
analyticsEventHandler.send(errorEvent.toEvent())
throw e
} catch (e: ApiResponseError.TimeoutException) {
} catch (e: ApiResponseError) {
val errorEvent = createErrorEvent(e)
analyticsEventHandler.send(errorEvent.toEvent())
throw e
}
}
private fun createListErrorEvent(error: ApiResponseError): MarketsDataAnalyticsEvent.List.Error {
return createErrorEvent(error) { errorType, errorCode ->
MarketsDataAnalyticsEvent.List.Error(
errorType = errorType,
errorCode = errorCode,
)
}
}
private fun createDetailsErrorEvent(
error: ApiResponseError,
request: MarketsDataAnalyticsEvent.Details.Error.Request,
tokenSymbol: String,
): MarketsDataAnalyticsEvent.Details.Error {
return createErrorEvent(error) { errorType, errorCode ->
MarketsDataAnalyticsEvent.Details.Error(
errorType = errorType,
errorCode = errorCode,
request = request,
tokenSymbol = tokenSymbol,
)
}
}
private inline fun <T> createErrorEvent(
error: ApiResponseError,
createEvent: (MarketsDataAnalyticsEvent.Type, Int?) -> T,
): T {
return when (error) {
is ApiResponseError.HttpException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Http, error.code.code)
}
is ApiResponseError.TimeoutException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Timeout, null)
}
is ApiResponseError.NetworkException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Network, null)
}
is ApiResponseError.UnknownException -> {
createEvent(MarketsDataAnalyticsEvent.Type.Unknown, null)
}
}
}
}

View file

@ -25,7 +25,7 @@ internal class MarketsBatchUpdateFetcher(
private val marketsApi: TangemTechMarketsApi,
private val tangemTechApi: TangemTechApi,
private val analyticsEventHandler: AnalyticsEventHandler,
private val onApiError: () -> Unit,
private val onApiError: (ApiResponseError) -> Unit,
) : BatchUpdateFetcher<Int, List<TokenMarket>, TokenMarketUpdateRequest> {
override suspend fun BatchUpdateFetcher.UpdateContext<Int, List<TokenMarket>>.fetchUpdateAsync(
@ -124,6 +124,7 @@ internal class MarketsBatchUpdateFetcher(
analyticsEventHandler.send(
MarketsDataAnalyticsEvent.ChartNullValuesError(
requestPath = "coins/history_preview",
errorType = MarketsDataAnalyticsEvent.Type.Custom,
),
)
@ -133,11 +134,11 @@ internal class MarketsBatchUpdateFetcher(
}
}
private inline fun <T> catchApiError(onError: () -> Unit, block: () -> T): T {
private inline fun <T> catchApiError(onError: (ApiResponseError) -> Unit, block: () -> T): T {
return try {
block()
} catch (e: ApiResponseError) {
onError()
onError(e)
throw e
}
}

View file

@ -9,7 +9,16 @@ sealed interface MarketsDataAnalyticsEvent {
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Markets", event = event, params = params), MarketsDataAnalyticsEvent {
data object Error : List(event = "Data Error")
data class Error(
val errorType: Type,
val errorCode: Int? = null,
) : List(
event = "Data Error",
params = buildMap {
put("Error Type", errorType.value)
errorCode?.let { put("Error Code", it.toString()) }
},
)
}
sealed class Details(
@ -20,12 +29,16 @@ sealed interface MarketsDataAnalyticsEvent {
data class Error(
val request: Request,
val tokenSymbol: String,
val errorType: Type,
val errorCode: Int? = null,
) : Details(
event = "Data Error",
params = mapOf(
"Source" to request.source,
"Token" to tokenSymbol,
),
params = buildMap {
put("Source", request.source)
put("Token", tokenSymbol)
errorCode?.let { put("Error Code", it.toString()) }
put("Error Type", errorType.value)
},
) {
enum class Request(val source: String) {
@ -43,13 +56,25 @@ sealed interface MarketsDataAnalyticsEvent {
data class ChartNullValuesError(
val requestPath: String,
val errorType: Type,
val errorCode: Int? = null,
) : AnalyticsEvent(
category = "Markets / Chart",
event = "Data Error",
params = mapOf("Request path" to requestPath),
error = IllegalStateException(
"Chart data contains null values from the API",
),
params = buildMap {
put("Request path", requestPath)
errorCode?.let { put("Error Code", it.toString()) }
put("Error Type", errorType.value)
put("Error Description", "Chart data contains null values from the API")
},
),
MarketsDataAnalyticsEvent
enum class Type(val value: String) {
Http("Http"),
Timeout("Timeout"),
Network("Network"),
Custom("Custom"),
Unknown("Unknown"),
}
}

View file

@ -13,6 +13,7 @@ import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.blockchainsdk.utils.toMigratedCointId
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toCompressedPublicKey
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.staking.converters.*
import com.tangem.data.staking.converters.action.ActionStatusConverter
@ -447,11 +448,20 @@ internal class DefaultStakingRepository(
return@invokeOnExpire
}
val result = stakeKitApi
.getMultipleYieldBalances(availableCurrencies)
.getOrThrow()
val yieldBalances = safeApiCall(
call = {
stakeKitApi
.getMultipleYieldBalances(availableCurrencies)
.bind()
},
onError = {
Timber.e(it, "Unable to fetch yield balances")
cacheRegistry.invalidate(getYieldBalancesKey(userWalletId))
emptySet()
},
)
stakingBalanceStore.store(userWalletId, result)
stakingBalanceStore.store(userWalletId, yieldBalances)
},
)
}

View file

@ -28,10 +28,11 @@ class YieldConverter(
metadata = convertMetadata(value.metadata.asMandatory("metadata")),
validators = value.validators.asMandatory("validators")
.asSequence()
.distinctBy { it.address }
.filter { it.status == ValidatorStatusDTO.ACTIVE }
.map { convertValidator(it) }
.sortedByDescending { it.isStrategicPartner }
.sortedByDescending { it.apr }
.sortedByDescending { it.isStrategicPartner }
.toImmutableList(),
isAvailable = value.isAvailable.asMandatory("isAvailable"),
)

View file

@ -45,7 +45,7 @@ internal class DefaultQuotesRepository(
.filterNotNull()
.flatMapLatest { appCurrency ->
fetchExpiredQuotes(currenciesIds, appCurrency.id, refresh = refresh)
quotesStore.get(currenciesIds).map(quotesConverter::convertSet)
quotesStore.get(currenciesIds).map { quotesConverter.convert(currenciesIds to it) }
}
.cancellable()
.flowOn(dispatchers.io)
@ -77,14 +77,15 @@ internal class DefaultQuotesRepository(
val quotes = quotesStore.getSync(currenciesIds)
quotesConverter.convertSet(quotes)
quotesConverter.convert(currenciesIds to quotes)
}
}
override suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote? {
return withContext(dispatchers.io) {
val quote = quotesStore.getSync(setOf(currencyId)).firstOrNull()
quote?.let { quotesConverter.convert(it) }
val setOfCurrencyId = setOf(currencyId)
val quote = quotesStore.getSync(setOfCurrencyId).firstOrNull()
quote?.let { quotesConverter.convert(setOfCurrencyId to setOf(it)).firstOrNull() }
}
}

View file

@ -1,16 +1,28 @@
package com.tangem.data.tokens.utils
import com.tangem.datasource.local.quote.model.StoredQuote
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class QuotesConverter : Converter<StoredQuote, Quote> {
typealias QuotesConverterValue = Pair<Set<CryptoCurrency.ID>, Set<StoredQuote>>
override fun convert(value: StoredQuote): Quote {
val (rawCurrencyId, responseQuote) = value
internal class QuotesConverter : Converter<QuotesConverterValue, Set<Quote>> {
return Quote(
override fun convert(value: QuotesConverterValue): Set<Quote> {
val (setOfCurrencyId, setOfStoredQuote) = value
return setOfCurrencyId.mapTo(hashSetOf()) { id ->
setOfStoredQuote.find { id.rawCurrencyId == it.rawCurrencyId }
?.let(::convertExistStoredQuote)
?: Quote.Empty(id.rawCurrencyId)
}
}
private fun convertExistStoredQuote(storedQuote: StoredQuote): Quote {
val (rawCurrencyId, responseQuote) = storedQuote
return Quote.Value(
rawCurrencyId = rawCurrencyId,
fiatRate = responseQuote.price ?: BigDecimal.ZERO,
priceChange = (responseQuote.priceChange24h ?: BigDecimal.ZERO).movePointLeft(2),

View file

@ -15,10 +15,10 @@ class GetCurrencyQuotesUseCase(
currencyID: CryptoCurrency.ID,
interval: PriceChangeInterval,
refresh: Boolean,
): Flow<Option<Quote>> {
): Flow<Option<Quote.Value>> {
return quotesRepository.getQuotesUpdates(
currenciesIds = setOf(currencyID),
refresh = refresh,
).map { it.firstOrNull().toOption() }.catch { emit(None) }
).map { it.filterIsInstance<Quote.Value>().firstOrNull().toOption() }.catch { emit(None) }
}
}

View file

@ -2,15 +2,27 @@ package com.tangem.domain.tokens.model
import java.math.BigDecimal
/**
* Represents financial information for a specific cryptocurrency, including its fiat exchange rate and price change.
*
* @property rawCurrencyId The unique identifier of the cryptocurrency for which the financial information is provided.
* @property fiatRate The current fiat exchange rate for the cryptocurrency.
* @property priceChange The price change for the cryptocurrency.
*/
data class Quote(
val rawCurrencyId: String,
val fiatRate: BigDecimal,
val priceChange: BigDecimal,
)
sealed interface Quote {
val rawCurrencyId: String?
/**
* Represents unknown financial information for a specific cryptocurrency.
*
* @property rawCurrencyId The raw cryptocurrency ID. If it is a custom token, the value will be `null`.
*/
data class Empty(override val rawCurrencyId: String?) : Quote
/**
* Represents financial information for a specific cryptocurrency, including its fiat exchange rate and price change.
*
* @property rawCurrencyId The unique identifier of the cryptocurrency for which the financial information is provided.
* @property fiatRate The current fiat exchange rate for the cryptocurrency.
* @property priceChange The price change for the cryptocurrency.
*/
data class Value(
override val rawCurrencyId: String,
val fiatRate: BigDecimal,
val priceChange: BigDecimal,
) : Quote
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.tokens.model.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
/**
[REDACTED_AUTHOR]
@ -77,4 +78,43 @@ sealed class TokenScreenAnalyticsEvent(
event = "Staking Clicked",
params = mapOf("Token" to token),
)
class NoticeActionInactive(token: String, tokenAction: TokenAction, reason: String) : TokenScreenAnalyticsEvent(
"Notice - Action Inactive",
params = buildMap {
put("Token", token)
put("Action", tokenAction.action)
if (reason.isNotEmpty()) {
put("Reason", reason)
}
},
) {
sealed class TokenAction(val action: String) {
data object BuyAction : TokenAction("Buy")
data object SellAction : TokenAction("Sell")
data object SwapAction : TokenAction("Swap")
data object SendAction : TokenAction("Send")
data object ReceiveAction : TokenAction("Receive")
}
}
companion object {
private const val UNAVAILABLE = "Unavailable"
private const val EMPTY = "Empty"
private const val PENDING = "Pending"
fun ScenarioUnavailabilityReason.toReasonAnalyticsText(): String {
return when (this) {
is ScenarioUnavailabilityReason.BuyUnavailable -> UNAVAILABLE
is ScenarioUnavailabilityReason.EmptyBalance -> EMPTY
ScenarioUnavailabilityReason.None -> ""
is ScenarioUnavailabilityReason.NotExchangeable -> UNAVAILABLE
is ScenarioUnavailabilityReason.NotSupportedBySellService -> UNAVAILABLE
is ScenarioUnavailabilityReason.PendingTransaction -> PENDING
is ScenarioUnavailabilityReason.StakingUnavailable -> UNAVAILABLE
ScenarioUnavailabilityReason.UnassociatedAsset -> UNAVAILABLE
ScenarioUnavailabilityReason.Unreachable -> ""
}
}
}
}

View file

@ -12,6 +12,18 @@ internal class CurrencyStatusOperations(
private val ignoreQuote: Boolean,
) {
private val Quote?.fiatRate: BigDecimal?
get() = when (this) {
is Quote.Value -> this.fiatRate
is Quote.Empty, null -> null
}
private val Quote?.priceChange: BigDecimal?
get() = when (this) {
is Quote.Value -> this.priceChange
is Quote.Empty, null -> null
}
fun createTokenStatus(): CryptoCurrencyStatus = CryptoCurrencyStatus(currency, createStatus())
private fun createStatus(): CryptoCurrencyStatus.Value {
@ -74,7 +86,7 @@ internal class CurrencyStatusOperations(
null
}
return when {
ignoreQuote -> CryptoCurrencyStatus.NoQuote(
quote is Quote.Empty || ignoreQuote -> CryptoCurrencyStatus.NoQuote(
amount = amount,
hasCurrentNetworkTransactions = hasCurrentNetworkTransactions,
pendingTransactions = currentTransactions,
@ -91,8 +103,7 @@ internal class CurrencyStatusOperations(
networkAddress = status.address,
yieldBalance = currentYieldBalance,
)
quote == null -> CryptoCurrencyStatus.Loading
else -> CryptoCurrencyStatus.Loaded(
quote is Quote.Value -> CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = calculateFiatAmount(amount, quote.fiatRate),
fiatRate = quote.fiatRate,
@ -102,6 +113,7 @@ internal class CurrencyStatusOperations(
networkAddress = status.address,
yieldBalance = currentYieldBalance,
)
else -> CryptoCurrencyStatus.Loading
}
}

View file

@ -7,65 +7,71 @@ import java.math.BigDecimal
@Suppress("MemberVisibilityCanBePrivate")
internal object MockQuotes {
val quote1 = Quote(
val quote1 = Quote.Value(
rawCurrencyId = MockTokens.token1.id.rawCurrencyId!!,
fiatRate = BigDecimal("1.23"),
priceChange = BigDecimal("0.01"),
)
val quote2 = Quote(
val quote2 = Quote.Value(
rawCurrencyId = MockTokens.token2.id.rawCurrencyId!!,
fiatRate = BigDecimal("2.34"),
priceChange = BigDecimal("-0.02"),
)
val quote3 = Quote(
val quote3 = Quote.Value(
rawCurrencyId = MockTokens.token3.id.rawCurrencyId!!,
fiatRate = BigDecimal("3.45"),
priceChange = BigDecimal("0.03"),
)
val quote4 = Quote(
val quote4 = Quote.Value(
rawCurrencyId = MockTokens.token4.id.rawCurrencyId!!,
fiatRate = BigDecimal("4.56"),
priceChange = BigDecimal("-0.04"),
)
val quote5 = Quote(
val quote5 = Quote.Value(
rawCurrencyId = MockTokens.token5.id.rawCurrencyId!!,
fiatRate = BigDecimal("5.67"),
priceChange = BigDecimal("0.05"),
)
val quote6 = Quote(
val quote6 = Quote.Value(
rawCurrencyId = MockTokens.token6.id.rawCurrencyId!!,
fiatRate = BigDecimal("6.78"),
priceChange = BigDecimal("-0.06"),
)
val quote7 = Quote(
val quote7 = Quote.Value(
rawCurrencyId = MockTokens.token7.id.rawCurrencyId!!,
fiatRate = BigDecimal("7.89"),
priceChange = BigDecimal("0.07"),
)
val quote8 = Quote(
val quote8 = Quote.Value(
rawCurrencyId = MockTokens.token8.id.rawCurrencyId!!,
fiatRate = BigDecimal("8.90"),
priceChange = BigDecimal("-0.08"),
)
val quote9 = Quote(
val quote9 = Quote.Value(
rawCurrencyId = MockTokens.token9.id.rawCurrencyId!!,
fiatRate = BigDecimal("9.01"),
priceChange = BigDecimal("0.09"),
)
val quote10 = Quote(
val quote10 = Quote.Value(
rawCurrencyId = MockTokens.token10.id.rawCurrencyId!!,
fiatRate = BigDecimal("10.12"),
priceChange = BigDecimal("-0.10"),
)
val quotes = nonEmptySetOf(quote1, quote2, quote3, quote4, quote5, quote6, quote7, quote8, quote9, quote10)
val quote11 = Quote.Empty(null)
val quote12 = Quote.Empty("null")
val quotes = nonEmptySetOf(
quote1, quote2, quote3, quote4, quote5, quote6, quote7, quote8, quote9, quote10,
quote11, quote12,
)
}

View file

@ -1,10 +1,7 @@
package com.tangem.domain.tokens.mock
import arrow.core.nonEmptyListOf
import com.tangem.domain.tokens.model.CryptoCurrencyAmountStatus
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.NetworkAddress
import com.tangem.domain.tokens.model.NetworkStatus
import com.tangem.domain.tokens.model.*
import java.math.BigDecimal
@Suppress("MemberVisibilityCanBePrivate")
@ -140,20 +137,29 @@ internal object MockTokensStates {
as? CryptoCurrencyAmountStatus.Loaded
)?.value ?: BigDecimal.ZERO
val quote = MockQuotes.quotes.first { it.rawCurrencyId == status.currency.id.rawCurrencyId }
val fiatAmount = amount * quote.fiatRate
status.copy(
value = CryptoCurrencyStatus.Loaded(
val value = when (quote) {
is Quote.Empty -> CryptoCurrencyStatus.NoQuote(
amount = status.value.amount!!,
pendingTransactions = emptySet(),
hasCurrentNetworkTransactions = false,
networkAddress = requireNotNull(
value = MockNetworks.verifiedNetworksStatuses.first { it.network == status.currency.network }.value as? NetworkStatus.Verified,
).address,
yieldBalance = null,
)
is Quote.Value -> CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = fiatAmount,
fiatAmount = amount * quote.fiatRate,
fiatRate = quote.fiatRate,
priceChange = quote.priceChange,
pendingTransactions = emptySet(),
hasCurrentNetworkTransactions = false,
networkAddress = requireNotNull(networkStatus.value as? NetworkStatus.Verified).address,
yieldBalance = null,
),
)
)
}
status.copy(value = value)
}
val noQuotesTokensStatuses = loadedTokensStates.map { status ->

View file

@ -4,7 +4,6 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.StartReferralBody
import com.tangem.datasource.demo.DemoModeDatasource
@ -24,7 +23,6 @@ internal class ReferralRepositoryImpl @Inject constructor(
private val referralConverter: ReferralConverter,
private val coroutineDispatcher: CoroutineDispatcherProvider,
private val demoModeDatasource: DemoModeDatasource,
private val authProvider: AuthProvider,
private val userWalletsStore: UserWalletsStore,
) : ReferralRepository {
@ -35,8 +33,6 @@ internal class ReferralRepositoryImpl @Inject constructor(
return withContext(coroutineDispatcher.io) {
referralConverter.convert(
referralApi.getReferralStatus(
cardPublicKey = authProvider.getCardPublicKey(),
cardId = authProvider.getCardId(),
walletId = walletId,
),
)
@ -52,8 +48,6 @@ internal class ReferralRepositoryImpl @Inject constructor(
return withContext(coroutineDispatcher.io) {
referralConverter.convert(
referralApi.startReferral(
cardPublicKey = authProvider.getCardPublicKey(),
cardId = authProvider.getCardId(),
startReferralBody = StartReferralBody(
walletId = walletId,
networkId = networkId,

View file

@ -1,6 +1,5 @@
package com.tangem.feature.referral.di
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.demo.DemoModeDatasource
import com.tangem.datasource.local.userwallet.UserWalletsStore
@ -25,7 +24,6 @@ class ReferralRepositoryModule {
referralConverter: ReferralConverter,
coroutineDispatcherProvider: CoroutineDispatcherProvider,
demoModeDatasource: DemoModeDatasource,
authProvider: AuthProvider,
userWalletsStore: UserWalletsStore,
): ReferralRepository {
return ReferralRepositoryImpl(
@ -33,7 +31,6 @@ class ReferralRepositoryModule {
referralConverter = referralConverter,
coroutineDispatcher = coroutineDispatcherProvider,
demoModeDatasource = demoModeDatasource,
authProvider = authProvider,
userWalletsStore = userWalletsStore,
)
}

View file

@ -2180,8 +2180,8 @@ internal class SwapInteractorImpl @AssistedInject constructor(
)
}
private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map<CryptoCurrency.ID, Quote> {
val set = ids.toSet().getQuotesOrEmpty(false)
private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map<CryptoCurrency.ID, Quote.Value> {
val set = ids.toSet().getQuotesOrEmpty(false).filterIsInstance<Quote.Value>()
return ids
.mapNotNull { id -> set.find { it.rawCurrencyId == id.rawCurrencyId }?.let { id to it } }

View file

@ -973,8 +973,12 @@ internal class StateBuilder(
fun updateApproveType(uiState: SwapStateHolder, approveType: ApproveType): SwapStateHolder {
val config = uiState.bottomSheetConfig?.content as? GiveTxPermissionBottomSheetConfig
val permissionState = (uiState.permissionState as? GiveTxPermissionState.ReadyForRequest)?.copy(
approveType = approveType,
) ?: uiState.permissionState
return if (config != null) {
uiState.copy(
permissionState = permissionState,
bottomSheetConfig = uiState.bottomSheetConfig.copy(
content = config.copy(
data = config.data.copy(approveType = approveType),

View file

@ -1,6 +1,5 @@
package com.tangem.feature.swap.viewmodels
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapProvider
@ -16,7 +15,6 @@ data class SwapProcessDataState(
// Amount from input
val amount: String? = null,
val approveDataModel: RequestApproveStateData? = null,
val approveType: ApproveType? = null,
val swapDataModel: SwapDataModel? = null,
val selectedFee: TxFee? = null,
val tokensDataState: TokensDataStateExpress? = null,

View file

@ -10,6 +10,7 @@ import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.bundle.unbundle
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
@ -530,10 +531,7 @@ internal class SwapViewModel @Inject constructor(
swapDataModel: SwapDataModel?,
) {
dataState = if (permissionState is PermissionDataState.PermissionReadyForRequest) {
dataState.copy(
approveDataModel = permissionState.requestApproveData,
approveType = dataState.approveType ?: ApproveType.UNLIMITED,
)
dataState.copy(approveDataModel = permissionState.requestApproveData)
} else {
dataState.copy(
swapDataModel = swapDataModel,
@ -673,9 +671,10 @@ internal class SwapViewModel @Inject constructor(
val approveDataModel = requireNotNull(dataState.approveDataModel) {
"dataState.approveDataModel.spenderAddress shouldn't be null"
}
val approveType = requireNotNull(dataState.approveType?.toDomainApproveType()) {
"uiState.permissionState should not be null"
}
val approveType =
requireNotNull(uiState.permissionState.getApproveTypeOrNull()?.toDomainApproveType()) {
"uiState.permissionState should not be null"
}
val feeForPermission = when (val fee = approveDataModel.fee) {
TxFeeState.Empty -> {
makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
@ -1026,7 +1025,6 @@ internal class SwapViewModel @Inject constructor(
onAmountSelected = { onAmountSelected(it) },
onChangeApproveType = { approveType ->
uiState = stateBuilder.updateApproveType(uiState, approveType)
dataState = dataState.copy(approveType = approveType)
},
onClickFee = {
val selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL
@ -1246,7 +1244,7 @@ internal class SwapViewModel @Inject constructor(
private fun sendPermissionApproveClickedEvent() {
val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol
val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol
val approveType = dataState.approveType
val approveType = uiState.permissionState.getApproveTypeOrNull()
if (sendTokenSymbol != null && receiveTokenSymbol != null && approveType != null) {
analyticsEventHandler.send(
SwapEvents.ButtonPermissionApproveClicked(

View file

@ -3,9 +3,9 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.core.ui.utils.toTimeFormat
@ -55,16 +55,22 @@ internal class TokenDetailsSwapTransactionsStateConverter(
.forEach { swapTransaction ->
val toCryptoCurrency = swapTransaction.toCryptoCurrency
val fromCryptoCurrency = swapTransaction.fromCryptoCurrency
val toCryptoCurrencyRawId = swapTransaction.toCryptoCurrency.id.rawCurrencyId
val fromCryptoCurrencyRawId = swapTransaction.fromCryptoCurrency.id.rawCurrencyId
swapTransaction.transactions.forEach { transaction ->
val toAmount = transaction.toCryptoAmount
val fromAmount = transaction.fromCryptoAmount
val toFiatAmount = quotes.firstOrNull {
it.rawCurrencyId == swapTransaction.toCryptoCurrency.id.rawCurrencyId
}?.fiatRate?.multiply(toAmount)
val fromFiatAmount = quotes.firstOrNull {
it.rawCurrencyId == swapTransaction.fromCryptoCurrency.id.rawCurrencyId
}?.fiatRate?.multiply(fromAmount)
var toFiatAmount: BigDecimal? = null
var fromFiatAmount: BigDecimal? = null
quotes.forEach { quote ->
if (quote is Quote.Value && quote.rawCurrencyId == toCryptoCurrencyRawId) {
toFiatAmount = quote.fiatRate.multiply(toAmount)
}
if (quote is Quote.Value && quote.rawCurrencyId == fromCryptoCurrencyRawId) {
fromFiatAmount = quote.fiatRate.multiply(fromAmount)
}
}
val timestamp = transaction.timestamp
val notifications =
getNotification(transaction.status?.status, transaction.status?.txExternalUrl, null)

View file

@ -48,6 +48,7 @@ import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent
import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent.Companion.toReasonAnalyticsText
import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.transaction.error.AssociateAssetError
@ -441,7 +442,14 @@ internal class TokenDetailsViewModel @Inject constructor(
override fun onBuyClick(unavailabilityReason: ScenarioUnavailabilityReason) {
analyticsEventsHandler.send(TokenScreenAnalyticsEvent.ButtonBuy(cryptoCurrency.symbol))
if (handleUnavailabilityReason(unavailabilityReason)) return
if (handleUnavailabilityReason(
unavailabilityReason = unavailabilityReason,
tokenSymbol = cryptoCurrency.symbol,
tokenAction = TokenScreenAnalyticsEvent.NoticeActionInactive.TokenAction.BuyAction,
)
) {
return
}
showErrorIfDemoModeOrElse {
val status = cryptoCurrencyStatus ?: return@showErrorIfDemoModeOrElse
@ -477,7 +485,14 @@ internal class TokenDetailsViewModel @Inject constructor(
override fun onSendClick(unavailabilityReason: ScenarioUnavailabilityReason) {
analyticsEventsHandler.send(TokenScreenAnalyticsEvent.ButtonSend(cryptoCurrency.symbol))
if (handleUnavailabilityReason(unavailabilityReason)) return
if (handleUnavailabilityReason(
unavailabilityReason = unavailabilityReason,
tokenSymbol = cryptoCurrency.symbol,
tokenAction = TokenScreenAnalyticsEvent.NoticeActionInactive.TokenAction.SendAction,
)
) {
return
}
sendCurrency(status = cryptoCurrencyStatus ?: return)
}
@ -546,7 +561,14 @@ internal class TokenDetailsViewModel @Inject constructor(
override fun onReceiveClick(unavailabilityReason: ScenarioUnavailabilityReason) {
val networkAddress = cryptoCurrencyStatus?.value?.networkAddress ?: return
if (handleUnavailabilityReason(unavailabilityReason)) return
if (handleUnavailabilityReason(
unavailabilityReason = unavailabilityReason,
tokenSymbol = cryptoCurrency.symbol,
tokenAction = TokenScreenAnalyticsEvent.NoticeActionInactive.TokenAction.ReceiveAction,
)
) {
return
}
viewModelScope.launch(dispatchers.main) {
analyticsEventsHandler.send(TokenScreenAnalyticsEvent.ButtonReceive(cryptoCurrency.symbol))
@ -566,7 +588,14 @@ internal class TokenDetailsViewModel @Inject constructor(
}
override fun onStakeClick(unavailabilityReason: ScenarioUnavailabilityReason) {
if (handleUnavailabilityReason(unavailabilityReason)) return
if (handleUnavailabilityReason(
unavailabilityReason = unavailabilityReason,
tokenSymbol = cryptoCurrency.symbol,
tokenAction = null,
)
) {
return
}
openStaking()
}
@ -598,7 +627,14 @@ internal class TokenDetailsViewModel @Inject constructor(
override fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) {
analyticsEventsHandler.send(TokenScreenAnalyticsEvent.ButtonSell(cryptoCurrency.symbol))
if (handleUnavailabilityReason(unavailabilityReason)) return
if (handleUnavailabilityReason(
unavailabilityReason = unavailabilityReason,
tokenSymbol = cryptoCurrency.symbol,
tokenAction = TokenScreenAnalyticsEvent.NoticeActionInactive.TokenAction.SellAction,
)
) {
return
}
showErrorIfDemoModeOrElse {
val status = cryptoCurrencyStatus ?: return@showErrorIfDemoModeOrElse
@ -615,7 +651,14 @@ internal class TokenDetailsViewModel @Inject constructor(
override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) {
analyticsEventsHandler.send(TokenScreenAnalyticsEvent.ButtonExchange(cryptoCurrency.symbol))
if (handleUnavailabilityReason(unavailabilityReason)) return
if (handleUnavailabilityReason(
unavailabilityReason = unavailabilityReason,
tokenSymbol = cryptoCurrency.symbol,
tokenAction = TokenScreenAnalyticsEvent.NoticeActionInactive.TokenAction.SwapAction,
)
) {
return
}
appRouter.push(AppRoute.Swap(currencyFrom = cryptoCurrency, userWalletId = userWalletId))
}
@ -849,8 +892,21 @@ internal class TokenDetailsViewModel @Inject constructor(
internalUiState.value = stateFactory.getStateWithUpdatedBalanceSegmentedButtonConfig(config)
}
private fun handleUnavailabilityReason(unavailabilityReason: ScenarioUnavailabilityReason): Boolean {
private fun handleUnavailabilityReason(
unavailabilityReason: ScenarioUnavailabilityReason,
tokenSymbol: String,
tokenAction: TokenScreenAnalyticsEvent.NoticeActionInactive.TokenAction?,
): Boolean {
if (unavailabilityReason == ScenarioUnavailabilityReason.None) return false
if (tokenAction != null) {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.NoticeActionInactive(
token = tokenSymbol,
tokenAction = tokenAction,
reason = unavailabilityReason.toReasonAnalyticsText(),
),
)
}
internalUiState.value = stateFactory.getStateWithActionButtonErrorDialog(unavailabilityReason)

View file

@ -114,6 +114,10 @@ sealed class WalletScreenAnalyticsEvent {
data object BackupError : MainScreen(event = "Notice - Backup Error")
data object NotePromo : MainScreen(event = "Notice - Note Promo")
data object NotePromoButton : MainScreen(event = "Note Promo Button")
data object UnlockAllWithBiometrics : MainScreen(event = "Button - Unlock All With Biometrics")
data object UnlockWithCardScan : MainScreen(event = "Button - Unlock With Card Scan")

View file

@ -35,6 +35,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
return warnings.mapNotNullTo(mutableSetOf(), ::getEvent)
}
@Suppress("CyclomaticComplexMethod")
private fun getEvent(warning: WalletNotification): AnalyticsEvent? {
return when (warning) {
is WalletNotification.Critical.DevCard -> MainScreen.DevelopmentCard
@ -46,6 +47,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
is WalletNotification.Informational.MissingAddresses -> MainScreen.MissingAddresses
is WalletNotification.RateApp -> MainScreen.HowDoYouLikeTangem
is WalletNotification.Critical.BackupError -> MainScreen.BackupError
is WalletNotification.NoteMigration -> MainScreen.NotePromo
is WalletNotification.SwapPromo -> TokenSwapPromoAnalyticsEvent.NoticePromotionBanner(
source = AnalyticsParam.ScreensSources.Main,
programName = TokenSwapPromoAnalyticsEvent.ProgramName.OKX,

View file

@ -41,7 +41,7 @@ internal class GetSingleWalletWarningsFactory @Inject constructor(
buildList {
addCriticalNotifications(cardTypesResolver)
addInformationalNotifications(cardTypesResolver)
addInformationalNotifications(cardTypesResolver, clickIntents)
addWarningNotifications(
userWallet,
@ -75,7 +75,17 @@ internal class GetSingleWalletWarningsFactory @Inject constructor(
}
}
private fun MutableList<WalletNotification>.addInformationalNotifications(cardTypesResolver: CardTypesResolver) {
private fun MutableList<WalletNotification>.addInformationalNotifications(
cardTypesResolver: CardTypesResolver,
clickIntents: WalletClickIntents,
) {
addIf(
element = WalletNotification.NoteMigration(
onClick = { clickIntents.onNoteMigrationButtonClick(NOTE_MIGRATION_URL) },
),
condition = cardTypesResolver.isTangemNote(),
)
addIf(
element = WalletNotification.Informational.DemoCard,
condition = isDemoCardUseCase(cardId = cardTypesResolver.getCardId()),
@ -160,13 +170,17 @@ internal class GetSingleWalletWarningsFactory @Inject constructor(
private fun MutableList<WalletNotification>.addIf(element: WalletNotification, condition: Boolean) {
if (condition) {
add(element = element)
if (element is WalletNotification.Critical || element is WalletNotification.Warning) {
if (element is WalletNotification.Critical ||
element is WalletNotification.Warning ||
element is WalletNotification.NoteMigration
) {
readyForRateAppNotification = false
}
}
}
private companion object {
const val NOTE_MIGRATION_URL = "https://tangem.com/en/?promocode=Note10"
const val MAX_REMAINING_SIGNATURES_COUNT = 10
}
}

View file

@ -2,10 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.model
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.extensions.*
import com.tangem.feature.wallet.impl.R
import org.joda.time.DateTime
@ -143,7 +140,7 @@ sealed class WalletNotification(val config: NotificationConfig) {
),
)
object DemoCard : Informational(
data object DemoCard : Informational(
title = resourceReference(id = R.string.warning_demo_mode_title),
subtitle = resourceReference(id = R.string.warning_demo_mode_message),
)
@ -204,4 +201,16 @@ sealed class WalletNotification(val config: NotificationConfig) {
onCloseClick = onCloseClick,
),
)
data class NoteMigration(val onClick: () -> Unit) : WalletNotification(
config = NotificationConfig(
title = resourceReference(R.string.wallet_promo_banner_title),
subtitle = resourceReference(R.string.wallet_promo_banner_description),
iconResId = R.drawable.banner_note_migration,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.wallet_promo_banner_button_title),
onClick = onClick,
),
),
)
}

View file

@ -1,9 +1,9 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.common
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.notifications.NoteMigrationNotification
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.OkxPromoNotification
import com.tangem.core.ui.components.notifications.RingPromoNotification
@ -19,7 +19,6 @@ import kotlinx.collections.immutable.ImmutableList
*
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.notifications(configs: ImmutableList<WalletNotification>, modifier: Modifier = Modifier) {
items(
items = configs,
@ -31,16 +30,25 @@ internal fun LazyListScope.notifications(configs: ImmutableList<WalletNotificati
is WalletNotification.SwapPromo -> {
OkxPromoNotification(
config = it.config,
modifier = modifier.animateItemPlacement(),
modifier = modifier.animateItem(),
)
}
is WalletNotification.RingPromo -> {
RingPromoNotification(config = it.config, modifier = modifier.animateItemPlacement())
RingPromoNotification(
config = it.config,
modifier = modifier.animateItem(),
)
}
is WalletNotification.NoteMigration -> {
NoteMigrationNotification(
config = it.config,
modifier = modifier.animateItem(),
)
}
else -> {
Notification(
config = it.config,
modifier = modifier.animateItemPlacement(),
modifier = modifier.animateItem(),
iconTint = when (it) {
is WalletNotification.Critical -> TangemTheme.colors.icon.warning
is WalletNotification.Informational -> TangemTheme.colors.icon.accent

View file

@ -63,6 +63,8 @@ internal interface WalletWarningsClickIntents {
fun onCloseRingPromoClick()
fun onSupportClick()
fun onNoteMigrationButtonClick(url: String)
}
@Suppress("LongParameterList")
@ -263,6 +265,13 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
}
}
override fun onNoteMigrationButtonClick(url: String) {
analyticsEventHandler.send(MainScreen.NotePromoButton)
viewModelScope.launch(dispatchers.main) {
router.openUrl(url)
}
}
private fun getSelectedUserWallet(): UserWallet? {
val userWalletId = stateHolder.getSelectedWalletId()
return getUserWalletUseCase(userWalletId).getOrElse {

View file

@ -6,7 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs = -Xmx6144m -XX:MaxMetaspaceSize=768m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.jvmargs = -Xmx6144m -XX:MaxMetaspaceSize=768m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects

View file

@ -87,7 +87,7 @@ markdownComposeView = "0.5.4"
# endregion Other libraries
# region Tangem
tangemBlockchainSdk = "develop-868"
tangemBlockchainSdk = "develop-871"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-403"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^

View file

@ -5,13 +5,11 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.extensions.Result
import com.tangem.common.extensions.toHexString
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CreateUserNetworkAccountBody
internal class DefaultAccountCreator(
private val authProvider: AuthProvider,
private val tangemTechApi: TangemTechApi,
) : AccountCreator {
@ -22,8 +20,6 @@ internal class DefaultAccountCreator(
)
return try {
val response = tangemTechApi.createUserNetworkAccount(
cardPublicKey = authProvider.getCardPublicKey(),
cardId = authProvider.getCardId(),
body = request,
).getOrThrow()
Result.Success(response.data.accountId)

View file

@ -10,7 +10,6 @@ import com.tangem.blockchainsdk.featuretoggles.DefaultBlockchainSDKFeatureToggle
import com.tangem.blockchainsdk.loader.BlockchainProvidersResponseLoader
import com.tangem.blockchainsdk.store.DefaultRuntimeStore
import com.tangem.core.featuretoggle.manager.FeatureTogglesManager
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -45,14 +44,13 @@ internal object BlockchainSDKFactoryModule {
@Provides
@Singleton
fun provideWalletManagerFactoryCreator(
authProvider: AuthProvider,
tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
blockchainSDKLogger: BlockchainSDKLogger,
featureTogglesManager: FeatureTogglesManager,
): WalletManagerFactoryCreator {
return WalletManagerFactoryCreator(
accountCreator = DefaultAccountCreator(authProvider, tangemTechApi),
accountCreator = DefaultAccountCreator(tangemTechApi),
blockchainDataStorage = DefaultBlockchainDataStorage(appPreferencesStore),
blockchainSDKLogger = blockchainSDKLogger,
blockchainSDKFeatureToggles = DefaultBlockchainSDKFeatureToggles(featureTogglesManager),