Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-08 15:09:07 +04:00
parent 8568277f95
commit ca2804b0e0
9 changed files with 172 additions and 48 deletions

View file

@ -50,6 +50,12 @@ interface TangemTechApi {
@Body userTokens: UserTokensResponse, @Body userTokens: UserTokensResponse,
): ApiResponse<Unit> ): ApiResponse<Unit>
@PUT("/v1/wallets/{walletId}/tokens")
suspend fun saveTokens(
@Path(value = "walletId") userId: String,
@Body userTokens: UserTokensResponse,
): ApiResponse<Unit>
/** Returns referral status by [walletId] */ /** Returns referral status by [walletId] */
@GET("v1/referral/{walletId}") @GET("v1/referral/{walletId}")
suspend fun getReferralStatus(@Path("walletId") walletId: String): ApiResponse<ReferralResponse> suspend fun getReferralStatus(@Path("walletId") walletId: String): ApiResponse<ReferralResponse>
@ -129,6 +135,12 @@ interface TangemTechApi {
@Body body: List<WalletIdBody>, @Body body: List<WalletIdBody>,
): ApiResponse<Unit> ): ApiResponse<Unit>
@PUT("/v1/user-wallets/applications/{application_id}/wallets")
suspend fun associateApplicationIdWithWalletsV2(
@Path("application_id") applicationId: String,
@Body body: AssociateApplicationIdWithWalletsBody,
): ApiResponse<Unit>
@GET("v1/user-wallets/wallets/{wallet_id}") @GET("v1/user-wallets/wallets/{wallet_id}")
suspend fun getWalletById(@Path("wallet_id") walletId: String): ApiResponse<WalletResponse> suspend fun getWalletById(@Path("wallet_id") walletId: String): ApiResponse<WalletResponse>

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AssociateAppWithWalletsErrorResponse(
@Json(name = "missingWalletIds") val missingWalletIds: List<String>,
)

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AssociateApplicationIdWithWalletsBody(
@Json(name = "walletIds") val walletIds: List<String>,
)

View file

@ -2,12 +2,17 @@ package com.tangem.data.common.currency
import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.tokens.UserTokensBackwardCompatibility import com.tangem.data.common.tokens.UserTokensBackwardCompatibility
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.isNetworkError
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.WalletType import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.retryer.Retryer import com.tangem.utils.retryer.Retryer
@ -50,24 +55,26 @@ class UserTokensSaver(
response: UserTokensResponse, response: UserTokensResponse,
useEnricher: Boolean = true, useEnricher: Boolean = true,
onFailSend: () -> Unit = {}, onFailSend: () -> Unit = {},
) { ) = withContext(dispatchers.io) {
withContext(dispatchers.default) { val userWallet = userWalletsStore.getSyncOrNull(key = userWalletId)
val userWallet = userWalletsStore.getSyncOrNull(key = userWalletId)
if (userWallet == null) {
Timber.e("UserWallet with id $userWalletId not found. Cannot push tokens.")
onFailSend()
return@withContext
}
if (accountsFeatureToggles.isFeatureEnabled) {
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher)
pushNew(userWallet = userWallet, response = enrichedResponse, onFailSend = onFailSend)
} else {
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher).copy( val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher).copy(
walletName = userWallet?.name, walletName = userWallet.name,
walletType = WalletType.from(userWallet), walletType = WalletType.from(userWallet),
) )
safeApiCall( pushLegacy(userWalletId = userWalletId, response = enrichedResponse, onFailSend = onFailSend)
call = {
withContext(dispatchers.io) {
tangemTechApi.saveUserTokens(userId = userWalletId.stringValue, userTokens = enrichedResponse)
.bind()
}
},
onError = { onFailSend() },
)
} }
} }
@ -88,6 +95,39 @@ class UserTokensSaver(
) )
} }
private suspend fun pushLegacy(userWalletId: UserWalletId, response: UserTokensResponse, onFailSend: () -> Unit) {
safeApiCall(
call = { tangemTechApi.saveUserTokens(userId = userWalletId.stringValue, userTokens = response).bind() },
onError = { onFailSend() },
)
}
private suspend fun pushNew(userWallet: UserWallet, response: UserTokensResponse, onFailSend: () -> Unit) {
safeApiCall(
call = {
val apiResponse = tangemTechApi.saveTokens(
userId = userWallet.walletId.stringValue,
userTokens = response,
)
val isWalletNotFound = apiResponse is ApiResponse.Error &&
apiResponse.cause.isNetworkError(ApiResponseError.HttpException.Code.NOT_FOUND)
if (isWalletNotFound) {
tangemTechApi.createWallet(body = WalletIdBodyConverter.convert(userWallet)).bind()
tangemTechApi.saveTokens(
userId = userWallet.walletId.stringValue,
userTokens = response,
).bind()
} else {
apiResponse.bind()
}
},
onError = { onFailSend() },
)
}
private fun UserTokensResponse.applyCompatibility(): UserTokensResponse { private fun UserTokensResponse.applyCompatibility(): UserTokensResponse {
return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this) return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this)
} }

View file

@ -73,7 +73,7 @@ class UserTokensSaverTest {
} }
coVerify(inverse = true) { coVerify(inverse = true) {
tangemTechApi.saveUserTokens(any(), any()) tangemTechApi.saveTokens(any(), any())
} }
} }
@ -108,7 +108,8 @@ class UserTokensSaverTest {
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
coEvery { enricher(userWalletId, response) } returns enrichedResponse coEvery { enricher(userWalletId, response) } returns enrichedResponse
coEvery { tangemTechApi.saveUserTokens(any(), any()) } returns ApiResponse.Error(error) as ApiResponse<Unit> coEvery { tangemTechApi.saveTokens(any(), any()) } returns ApiResponse.Error(error) as ApiResponse<Unit>
coEvery { tangemTechApi.createWallet(body = any()) } returns ApiResponse.Error(error) as ApiResponse<Unit>
// WHEN // WHEN
userTokensSaver.push( userTokensSaver.push(
@ -120,7 +121,7 @@ class UserTokensSaverTest {
// THEN // THEN
coVerifyOrder { coVerifyOrder {
enricher(userWalletId, response) enricher(userWalletId, response)
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse) tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse)
} }
assert(onFailSendCalled) { "onFailSend callback should be called when API call fails" } assert(onFailSendCalled) { "onFailSend callback should be called when API call fails" }
@ -155,7 +156,7 @@ class UserTokensSaverTest {
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
coEvery { enricher(userWalletId, response) } returns enrichedResponse coEvery { enricher(userWalletId, response) } returns enrichedResponse
coEvery { coEvery {
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse) tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse)
} returns ApiResponse.Success(Unit) } returns ApiResponse.Success(Unit)
// WHEN // WHEN
@ -164,7 +165,7 @@ class UserTokensSaverTest {
// THEN // THEN
coVerifyOrder { coVerifyOrder {
enricher(userWalletId, response) enricher(userWalletId, response)
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse) tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse)
} }
} }
} }

View file

@ -25,13 +25,12 @@ dependencies {
implementation(projects.core.utils) implementation(projects.core.utils)
/** Domain */ /** Domain */
implementation(projects.domain.wallets) implementation(projects.domain.account)
implementation(projects.domain.card) implementation(projects.domain.card)
api(projects.domain.models) implementation(projects.domain.models)
/** Domain models */
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens.models) implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
/** DI */ /** DI */
implementation(deps.hilt.android) implementation(deps.hilt.android)
@ -41,15 +40,15 @@ dependencies {
implementation(deps.androidx.datastore) implementation(deps.androidx.datastore)
implementation(deps.arrow.core) implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines) implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.retrofit)
implementation(deps.timber) implementation(deps.timber)
/** tests */ /** tests */
testImplementation(projects.domain.models)
testImplementation(projects.common.test) testImplementation(projects.common.test)
testImplementation(deps.test.junit) testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine) testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth) testImplementation(deps.test.truth)
testImplementation(deps.test.mockk) testImplementation(deps.test.mockk)
testImplementation(deps.moshi)
testImplementation(deps.moshi.kotlin)
} }

View file

@ -5,22 +5,22 @@ import arrow.core.left
import arrow.core.right import arrow.core.right
import com.tangem.data.wallets.converters.UserWalletRemoteInfoConverter import com.tangem.data.wallets.converters.UserWalletRemoteInfoConverter
import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException import com.tangem.datasource.api.common.response.ApiResponseError.HttpException
import com.tangem.datasource.api.common.response.fold import com.tangem.datasource.api.common.response.fold
import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.common.response.isNetworkError
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter
import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody import com.tangem.datasource.api.tangemTech.models.*
import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO
import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO.Status import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO.Status
import com.tangem.datasource.api.tangemTech.models.WalletBody
import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.PreferencesKeys.SEED_FIRST_NOTIFICATION_SHOW_TIME import com.tangem.datasource.local.preferences.PreferencesKeys.SEED_FIRST_NOTIFICATION_SHOW_TIME
import com.tangem.datasource.local.preferences.utils.* import com.tangem.datasource.local.preferences.utils.*
import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus
@ -30,13 +30,15 @@ import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.WEEK_MILLIS import com.tangem.utils.WEEK_MILLIS
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
typealias SeedPhraseNotificationsStatuses = Map<UserWalletId, SeedPhraseNotificationsStatus> typealias SeedPhraseNotificationsStatuses = Map<UserWalletId, SeedPhraseNotificationsStatus>
@Suppress("TooManyFunctions", "LargeClass") @Suppress("TooManyFunctions", "LargeClass", "LongParameterList")
internal class DefaultWalletsRepository( internal class DefaultWalletsRepository(
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val tangemTechApi: TangemTechApi, private val tangemTechApi: TangemTechApi,
@ -44,6 +46,8 @@ internal class DefaultWalletsRepository(
private val seedPhraseNotificationVisibilityStore: RuntimeStateStore<SeedPhraseNotificationsStatuses>, private val seedPhraseNotificationVisibilityStore: RuntimeStateStore<SeedPhraseNotificationsStatuses>,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
private val authProvider: AuthProvider, private val authProvider: AuthProvider,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val moshi: com.squareup.moshi.Moshi,
) : WalletsRepository { ) : WalletsRepository {
private val upgradeWalletNotificationDisabled: MutableStateFlow<Set<UserWalletId>> = private val upgradeWalletNotificationDisabled: MutableStateFlow<Set<UserWalletId>> =
@ -388,24 +392,58 @@ internal class DefaultWalletsRepository(
override suspend fun associateWallets(applicationId: String, wallets: List<UserWallet>) = override suspend fun associateWallets(applicationId: String, wallets: List<UserWallet>) =
withContext(dispatchers.io) { withContext(dispatchers.io) {
val publicKeys = authProvider.getCardsPublicKeys() if (accountsFeatureToggles.isFeatureEnabled) {
val walletsBody = wallets.map { userWallet -> val associateApplicationIdWithWallets: suspend () -> ApiResponse<Unit> = {
WalletIdBodyConverter.convert( tangemTechApi.associateApplicationIdWithWalletsV2(
userWallet = userWallet, applicationId = applicationId,
publicKeys = if (userWallet is UserWallet.Cold) { body = AssociateApplicationIdWithWalletsBody(
publicKeys.filterKeys { walletIds = wallets.map { it.walletId.stringValue }.distinct(),
userWallet.cardsInWallet.contains(it) ),
} )
} else { }
emptyMap()
},
)
}
tangemTechApi.associateApplicationIdWithWallets( val apiResponse = associateApplicationIdWithWallets()
applicationId = applicationId,
body = walletsBody, if (apiResponse is ApiResponse.Success) return@withContext
).getOrThrow()
if (apiResponse is ApiResponse.Error &&
apiResponse.cause.isNetworkError(HttpException.Code.BAD_REQUEST)
) {
val errorBody = (apiResponse.cause as? HttpException)?.errorBody
?: error("Bad Request must have error body")
val adapter = moshi.adapter(AssociateAppWithWalletsErrorResponse::class.java)
val errorResponse = adapter.fromJson(errorBody)
?: error("Cannot parse error body: $errorBody")
errorResponse.missingWalletIds
.map {
async { createWallet(userWalletId = UserWalletId(it)) }
}
.awaitAll()
associateApplicationIdWithWallets().getOrThrow()
}
} else {
val publicKeys = authProvider.getCardsPublicKeys()
val walletsBody = wallets.map { userWallet ->
WalletIdBodyConverter.convert(
userWallet = userWallet,
publicKeys = if (userWallet is UserWallet.Cold) {
publicKeys.filterKeys {
userWallet.cardsInWallet.contains(it)
}
} else {
emptyMap()
},
)
}
tangemTechApi.associateApplicationIdWithWallets(
applicationId = applicationId,
body = walletsBody,
).getOrThrow()
}
} }
override suspend fun activatePromoCode( override suspend fun activatePromoCode(

View file

@ -1,5 +1,6 @@
package com.tangem.data.wallets.di package com.tangem.data.wallets.di
import com.squareup.moshi.Moshi
import com.tangem.data.wallets.DefaultWalletNamesMigrationRepository import com.tangem.data.wallets.DefaultWalletNamesMigrationRepository
import com.tangem.data.wallets.DefaultWalletsRepository import com.tangem.data.wallets.DefaultWalletsRepository
import com.tangem.data.wallets.cold.DefaultColdMapDerivationsRepository import com.tangem.data.wallets.cold.DefaultColdMapDerivationsRepository
@ -8,9 +9,11 @@ import com.tangem.data.wallets.hot.DefaultHotMapDerivationsRepository
import com.tangem.data.wallets.hot.DefaultHotWalletAccessCodeAttemptsRepository import com.tangem.data.wallets.hot.DefaultHotWalletAccessCodeAttemptsRepository
import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.wallets.derivations.ColdMapDerivationsRepository import com.tangem.domain.wallets.derivations.ColdMapDerivationsRepository
import com.tangem.domain.wallets.derivations.DerivationsRepository import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository
@ -37,6 +40,8 @@ internal object WalletsDataModule {
userWalletsStore: UserWalletsStore, userWalletsStore: UserWalletsStore,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
authProvider: AuthProvider, authProvider: AuthProvider,
accountsFeatureToggles: AccountsFeatureToggles,
@NetworkMoshi moshi: Moshi,
): WalletsRepository { ): WalletsRepository {
return DefaultWalletsRepository( return DefaultWalletsRepository(
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
@ -45,6 +50,8 @@ internal object WalletsDataModule {
seedPhraseNotificationVisibilityStore = RuntimeStateStore(defaultValue = emptyMap()), seedPhraseNotificationVisibilityStore = RuntimeStateStore(defaultValue = emptyMap()),
dispatchers = dispatchers, dispatchers = dispatchers,
authProvider = authProvider, authProvider = authProvider,
accountsFeatureToggles = accountsFeatureToggles,
moshi = moshi,
) )
} }

View file

@ -13,6 +13,7 @@ import com.tangem.datasource.api.tangemTech.models.PromocodeActivationResponse
import com.tangem.datasource.api.tangemTech.models.WalletResponse import com.tangem.datasource.api.tangemTech.models.WalletResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError
@ -51,6 +52,8 @@ class DefaultWalletsRepositoryTest {
seedPhraseNotificationVisibilityStore = mockk(), seedPhraseNotificationVisibilityStore = mockk(),
dispatchers = dispatchers, dispatchers = dispatchers,
authProvider = mockk(), authProvider = mockk(),
accountsFeatureToggles = mockk(),
moshi = mockk(),
) )
} }
@ -204,6 +207,10 @@ class DefaultWalletsRepositoryTest {
coEvery { getCardsPublicKeys() } returns publicKeys coEvery { getCardsPublicKeys() } returns publicKeys
} }
val accountsFeatureToggles = mockk<AccountsFeatureToggles> {
every { isFeatureEnabled } returns false
}
repository = DefaultWalletsRepository( repository = DefaultWalletsRepository(
appPreferencesStore = appPreferenceStore, appPreferencesStore = appPreferenceStore,
tangemTechApi = tangemTechApi, tangemTechApi = tangemTechApi,
@ -211,6 +218,8 @@ class DefaultWalletsRepositoryTest {
seedPhraseNotificationVisibilityStore = mockk(), seedPhraseNotificationVisibilityStore = mockk(),
dispatchers = dispatchers, dispatchers = dispatchers,
authProvider = authProvider, authProvider = authProvider,
accountsFeatureToggles = accountsFeatureToggles,
moshi = mockk(),
) )
coEvery { coEvery {