diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt index b85ea4e10e..1b686d6be0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt @@ -14,10 +14,12 @@ sealed class ApiResponse { * Represents a successful response from the API * * @property data the data returned by the API + * @property code the HTTP status code of the response * @property headers the headers returned by the API */ data class Success( val data: T, + val code: ApiResponseError.HttpException.Code = ApiResponseError.HttpException.Code.OK, override val headers: Map> = emptyMap(), ) : ApiResponse() @@ -37,11 +39,20 @@ sealed class ApiResponse { * Wraps data in a [ApiResponse.Success] instance * * @param data the data to wrap + * @param code the HTTP status code of the response * @param headers the headers returned by the API * @return a [ApiResponse.Success] instance containing the provided data */ -internal fun apiSuccess(data: T, headers: Map>): ApiResponse { - return ApiResponse.Success(data, headers) +internal fun apiSuccess( + data: T, + code: ApiResponseError.HttpException.Code?, + headers: Map>, +): ApiResponse { + return ApiResponse.Success( + data = data, + code = code ?: ApiResponseError.HttpException.Code.OK, + headers = headers, + ) } /** diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt index 1028e2916f..8b818cff1b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt @@ -18,8 +18,13 @@ sealed class ApiResponseError : Exception() { val errorBody: String?, ) : ApiResponseError() { + // TODO: extract Code from HttpException // region Error Codes enum class Code(val numericCode: Int) { + // 2xx Success + OK(numericCode = 200), + CREATED(numericCode = 201), + ACCEPTED(numericCode = 202), // 3xx Server Errors NOT_MODIFIED(numericCode = 304), // 4xx Server Errors diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt index 68ad795052..ec7f06e90d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt @@ -15,11 +15,12 @@ internal fun Response.toSafeApiResponse(analyticsErrorHandler: Anal val headers = headers().toMultimap() val body = body() + val code = ApiResponseError.HttpException.Code.entries + .firstOrNull { it.numericCode == code() } + return if (isSuccessful && body != null) { - apiSuccess(data = body, headers = headers) + apiSuccess(data = body, code = code, headers = headers) } else { - val code = ApiResponseError.HttpException.Code.entries - .firstOrNull { it.numericCode == code() } val e = try { if (code == null) { ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}")) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index e838002aa8..2ffd9c59ba 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -137,6 +137,9 @@ interface TangemTechApi { @GET("v1/user-wallets/wallets/by-app/{app_id}") suspend fun getWallets(@Path("app_id") appId: String): ApiResponse> + + @POST("v1/user-wallets/wallets") + suspend fun createWallet(@Body body: OnlyWalletIdBody): ApiResponse // endregion // promo diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/OnlyWalletIdBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/OnlyWalletIdBody.kt new file mode 100644 index 0000000000..6b5be8cd81 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/OnlyWalletIdBody.kt @@ -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 OnlyWalletIdBody( + @Json(name = "id") val walletId: String, +) \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt index 2470bbfdfa..8b51092c49 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -1,35 +1,50 @@ package com.tangem.data.account.fetcher import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory +import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.common.response.ETAG_HEADER import com.tangem.datasource.api.common.response.isNetworkError +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.OnlyWalletIdBody import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject +import javax.inject.Singleton /** * Handles errors that occur during the fetching of wallet accounts * + * @property tangemTechApi API for network requests * @property userTokensSaver saves user tokens to the storage * @property userTokensResponseStore provides access to user token responses. * @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse] + * @property eTagsStore store for ETags to manage caching + * @property dispatchers dispatchers * * @see DefaultWalletAccountsFetcher * [REDACTED_AUTHOR] */ +@Singleton internal class FetchWalletAccountsErrorHandler @Inject constructor( + private val tangemTechApi: TangemTechApi, private val userTokensSaver: UserTokensSaver, private val userTokensResponseStore: UserTokensResponseStore, private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory, + private val eTagsStore: ETagsStore, + private val dispatchers: CoroutineDispatcherProvider, ) { /** @@ -58,16 +73,18 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( } } - var response = savedAccountsResponse ?: createDefaultResponse(userWalletId) + val response = savedAccountsResponse ?: createDefaultResponse(userWalletId) val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse() val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND) if (isNotFoundError) { - userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse) - val updatedResponse = pushWalletAccounts(userWalletId, accountDTOs) + val eTag = createWallet(userWalletId) - if (updatedResponse != null) { - response = updatedResponse + if (eTag != null) { + eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag) + + pushWalletAccounts(userWalletId, accountDTOs) + userTokensSaver.push(userWalletId, userTokensResponse) } } @@ -92,4 +109,24 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( } .also { userTokensResponseStore.clear(userWalletId) } } + + /** + * Creates a wallet on the server and returns the ETag if successful. + * + * @param userWalletId The ID of the user wallet to create. + + */ + private suspend fun createWallet(userWalletId: UserWalletId): String? { + val creationResponse = withContext(dispatchers.io) { + tangemTechApi.createWallet( + body = OnlyWalletIdBody(walletId = userWalletId.stringValue), + ) + } + + return if (creationResponse is ApiResponse.Success && creationResponse.code == Code.CREATED) { + creationResponse.headers[ETAG_HEADER]?.firstOrNull() + } else { + null + } + } } \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt index e8b46ce20c..dae6705e1c 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -3,15 +3,21 @@ package com.tangem.data.account.fetcher import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory +import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.datasource.api.common.response.ETAG_HEADER +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.OnlyWalletIdBody import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify @@ -27,14 +33,19 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class FetchWalletAccountsErrorHandlerTest { + private val tangemTechApi: TangemTechApi = mockk() private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true) private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk() + private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true) private val handler = FetchWalletAccountsErrorHandler( + tangemTechApi = tangemTechApi, userTokensSaver = userTokensSaver, userTokensResponseStore = userTokensResponseStore, defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, + eTagsStore = eTagsStore, + dispatchers = TestingCoroutineDispatcherProvider(), ) private val pushWalletAccounts: suspend (UserWalletId, List) -> GetWalletAccountsResponse = @@ -93,6 +104,16 @@ class FetchWalletAccountsErrorHandlerTest { val savedAccountsResponse = createGetWalletAccountsResponse(userWalletId) + val eTagValue = "etag-value" + val apiResponse = ApiResponse.Success( + data = Unit, + headers = mapOf(ETAG_HEADER to listOf(eTagValue)), + code = Code.CREATED, + ) + + coEvery { + tangemTechApi.createWallet(OnlyWalletIdBody(userWalletId.stringValue)) + } returns apiResponse coEvery { pushWalletAccounts(userWalletId, listOf(accountDTO)) } returns savedAccountsResponse // Act @@ -107,6 +128,8 @@ class FetchWalletAccountsErrorHandlerTest { // Assert coVerify { userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse()) + tangemTechApi.createWallet(OnlyWalletIdBody(userWalletId.stringValue)) + eTagsStore.store(userWalletId, ETagsStore.Key.WalletAccounts, eTagValue) pushWalletAccounts(userWalletId, listOf(accountDTO)) storeWalletAccounts(userWalletId, savedAccountsResponse) } diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt index 56a5f027a2..692e0dce0d 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -10,20 +10,13 @@ import com.tangem.datasource.api.common.response.ApiResponseError.HttpException import com.tangem.datasource.api.common.response.fold import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.models.MarkUserWalletWasCreatedBody -import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody -import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO +import com.tangem.datasource.api.tangemTech.models.* import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO.Status -import com.tangem.datasource.api.tangemTech.models.WalletBody import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.preferences.AppPreferencesStore 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.utils.get -import com.tangem.datasource.local.preferences.utils.getObjectMap -import com.tangem.datasource.local.preferences.utils.getSyncOrDefault -import com.tangem.datasource.local.preferences.utils.getSyncOrNull -import com.tangem.datasource.local.preferences.utils.store +import com.tangem.datasource.local.preferences.utils.* import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -37,7 +30,6 @@ import com.tangem.utils.coroutines.runCatching import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlin.collections.mutableSetOf typealias SeedPhraseNotificationsStatuses = Map @@ -52,7 +44,7 @@ internal class DefaultWalletsRepository( ) : WalletsRepository { private val upgradeWalletNotificationDisabled: MutableStateFlow> = - MutableStateFlow(mutableSetOf()) + MutableStateFlow(mutableSetOf()) override suspend fun shouldSaveUserWalletsSync(): Boolean { return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false) @@ -260,6 +252,14 @@ internal class DefaultWalletsRepository( } } + override suspend fun createWallet(userWalletId: UserWalletId) { + withContext(dispatchers.io) { + tangemTechApi.createWallet( + body = OnlyWalletIdBody(userWalletId.stringValue), + ) + } + } + override suspend fun rejectSeedPhraseSecondNotification(userWalletId: UserWalletId) { runCatching(dispatchers.io) { tangemTechApi.updateSeedPhraseSecondNotificationStatus( diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt index 7513e6e3bf..a10a8c5492 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt @@ -1,9 +1,9 @@ package com.tangem.domain.wallets.repository import arrow.core.Either -import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus import com.tangem.domain.wallets.models.UserWalletRemoteInfo import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import kotlinx.coroutines.flow.Flow @@ -45,6 +45,8 @@ interface WalletsRepository { suspend fun markWallet2WasCreated(userWalletId: UserWalletId) + suspend fun createWallet(userWalletId: UserWalletId) + fun nftEnabledStatus(userWalletId: UserWalletId): Flow fun nftEnabledStatuses(): Flow> diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index c91d117f06..d4436b86c7 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -233,6 +233,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( } } + @Suppress("LongMethod") private fun finishBackup() { modelScope.launch { setLoading(true) @@ -306,6 +307,10 @@ internal class MultiWalletFinalizeModel @Inject constructor( launch(NonCancellable) { walletsRepository.markWallet2WasCreated(userWallet.walletId) } } + if (userWallet.isMultiCurrency) { + launch(NonCancellable) { walletsRepository.createWallet(userWallet.walletId) } + } + // user wallet is fully created and saved, remove scan response from preferences // to prevent showing finalize screen dialog on next app start onboardingRepository.clearUnfinishedFinalizeOnboarding() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 5d318bb9ee..183f18f851 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -9,6 +9,7 @@ import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet @@ -96,6 +97,7 @@ internal class WalletModel @Inject constructor( private val yieldSupplyApyUpdateUseCase: YieldSupplyApyUpdateUseCase, private val tangemPayOnboardingRepository: OnboardingRepository, private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, + private val accountsFeatureToggles: AccountsFeatureToggles, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -531,21 +533,39 @@ internal class WalletModel @Inject constructor( } private suspend fun addWallet(action: WalletsUpdateActionResolver.Action.AddWallet) { - walletScreenContentLoader.load( - userWallet = action.selectedWallet, - clickIntents = clickIntents, - coroutineScope = modelScope, - ) + if (accountsFeatureToggles.isFeatureEnabled) { + fetchWalletContent(userWallet = action.selectedWallet) - fetchWalletContent(userWallet = action.selectedWallet) + stateHolder.update( + AddWalletTransformer( + userWallet = action.selectedWallet, + clickIntents = clickIntents, + walletImageResolver = walletImageResolver, + ), + ) - stateHolder.update( - AddWalletTransformer( + walletScreenContentLoader.load( userWallet = action.selectedWallet, clickIntents = clickIntents, - walletImageResolver = walletImageResolver, - ), - ) + coroutineScope = modelScope, + ) + } else { + walletScreenContentLoader.load( + userWallet = action.selectedWallet, + clickIntents = clickIntents, + coroutineScope = modelScope, + ) + + fetchWalletContent(userWallet = action.selectedWallet) + + stateHolder.update( + AddWalletTransformer( + userWallet = action.selectedWallet, + clickIntents = clickIntents, + walletImageResolver = walletImageResolver, + ), + ) + } scrollToWallet(prevIndex = action.prevWalletIndex, newIndex = action.selectedWalletIndex) { stateHolder.update { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt index 8fbd5a1d74..79601cf7d4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicTokenListSubscriber.kt @@ -14,10 +14,10 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.usecase.StakingApyFlowUseCase import com.tangem.domain.tokens.RunPolkadotAccountHealthCheckUseCase import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase -import com.tangem.domain.staking.usecase.StakingApyFlowUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender @@ -179,9 +179,8 @@ internal abstract class BasicTokenListSubscriber : WalletSubscriber() { val accountFlattenCurrencies = accountFlattenTokensList .map { it.flattenCurrencies() } .flatten() - val mainAccount: AccountStatus.CryptoPortfolio = when (val mainAccount = accountList.mainAccount) { - is AccountStatus.CryptoPortfolio -> mainAccount - } + + val mainAccount = accountList.mainAccount suspend fun singleAccountTransform(maybeTokenList: Lce) = this.singleAccountTransform(