From 5ec38a0d61fd36851bb4e084077dc8aed3de7810 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 17 Sep 2025 11:39:17 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/data/DefaultTangemPayStorage.kt | 52 +++++- .../tangem/datasource/api/pay/TangemPayApi.kt | 9 + .../models/request/DeeplinkValidityRequest.kt | 9 + ...RefreshCustomerWalletAccessTokenRequest.kt | 10 ++ .../pay/models/response/CustomerMeResponse.kt | 6 +- .../response/DeeplinkValidityResponse.kt | 15 ++ .../datasource/local/visa/TangemPayStorage.kt | 12 +- .../DefaultTangemPayAuthDataSource.kt | 44 +++-- .../tangem/data/pay/di/TangemPayDataModule.kt | 6 +- .../pay/repository/DefaultKycRepository.kt | 57 +------ .../repository/DefaultOnboardingRepository.kt | 40 +++++ .../repository/TangemPayRequestPerformer.kt | 159 ++++++++++++++++++ .../pay/usecase/DefaultKycStartInfoUseCase.kt | 36 ---- .../visa/DefaultVisaAuthRemoteDataSource.kt | 60 ++++--- .../pay/datasource/TangemPayAuthDataSource.kt | 5 +- .../tangem/domain/pay/model/CustomerInfo.kt | 19 +++ .../domain/pay/repository/KycRepository.kt | 9 +- .../pay/repository/OnboardingRepository.kt | 12 ++ .../domain/pay/usecase/KycStartInfoUseCase.kt | 10 -- .../datasource/VisaAuthRemoteDataSource.kt | 6 +- .../features/kyc/DefaultKycComponent.kt | 2 +- .../tangem/features/kyc/DefaultKycModel.kt | 6 +- .../tangem/features/kyc/MockKycComponent.kt | 2 +- 23 files changed, 424 insertions(+), 162 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/DeeplinkValidityRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshCustomerWalletAccessTokenRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/DeeplinkValidityResponse.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt delete mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultKycStartInfoUseCase.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt delete mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/KycStartInfoUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt index 1b9e1d54d7..f990fdc0fd 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -1,15 +1,20 @@ package com.tangem.tap.data import android.content.Context +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.visa.model.VisaAuthTokens import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton +import kotlin.text.encodeToByteArray private const val DEFAULT_KEY = "tangem_pay_default_key" +private const val DEFAULT_CUSTOMER_WALLET_ADDRESS_KEY = "tangem_pay_default_customer_wallet_address_key" @Singleton internal class DefaultTangemPayStorage @Inject constructor( @@ -24,16 +29,49 @@ internal class DefaultTangemPayStorage @Inject constructor( name = "tangem_pay_storage", ) } - - override suspend fun store(authHeader: String) = withContext(dispatcherProvider.io) { - secureStorage.store(authHeader.encodeToByteArray(throwOnInvalidSequence = true), DEFAULT_KEY) + private val moshi by lazy { + Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() } - override suspend fun get(): String? = withContext(dispatcherProvider.io) { - secureStorage.get(DEFAULT_KEY)?.decodeToString(throwOnInvalidSequence = true) + private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) } + + override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) = + withContext(dispatcherProvider.io) { + val json = tokensAdapter.toJson(tokens) + + secureStorage.store( + json.encodeToByteArray(throwOnInvalidSequence = true), + createKey(customerWalletAddress), + ) + } + + override suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens? = + withContext(dispatcherProvider.io) { + secureStorage.get(createKey(customerWalletAddress)) + ?.decodeToString(throwOnInvalidSequence = true) + ?.let(tokensAdapter::fromJson) + } + + /** + * Store the only customer wallet address, since for the f&f user can issue only one card tied to one address + */ + override suspend fun storeCustomerWalletAddress(customerWalletAddress: String) = + withContext(dispatcherProvider.io) { + secureStorage.store( + customerWalletAddress.encodeToByteArray(throwOnInvalidSequence = true), + DEFAULT_CUSTOMER_WALLET_ADDRESS_KEY, + ) + } + + override suspend fun getCustomerWalletAddress(): String? = withContext(dispatcherProvider.io) { + secureStorage.get(DEFAULT_CUSTOMER_WALLET_ADDRESS_KEY)?.decodeToString(throwOnInvalidSequence = true) } - override suspend fun clear() = withContext(dispatcherProvider.io) { - secureStorage.delete(DEFAULT_KEY) + override suspend fun clear(customerWalletAddress: String) = withContext(dispatcherProvider.io) { + secureStorage.delete(createKey(customerWalletAddress)) } + + private fun createKey(cardId: String): String = "${DEFAULT_KEY}_$cardId" } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 7549cc097f..b753ffc387 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -9,6 +9,7 @@ import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Query +@Suppress("TooManyFunctions") interface TangemPayApi { // region: auth @@ -32,6 +33,11 @@ interface TangemPayApi { @POST("v1/auth/token") suspend fun getTokenByCustomerWallet(@Body request: GetTokenByCustomerWalletRequest): ApiResponse + @POST("v1/auth/token/refresh") + suspend fun refreshCustomerWalletAccessToken( + @Body request: RefreshCustomerWalletAccessTokenRequest, + ): ApiResponse + @POST("v1/auth/token") suspend fun getAccessTokenByCardWallet(@Body request: GetAccessTokenByCardWalletRequest): ApiResponse @@ -106,4 +112,7 @@ interface TangemPayApi { @GET("v1/customer/me") suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse + + @POST("v1/deeplink/validate") + suspend fun validateDeeplink(@Body body: DeeplinkValidityRequest): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/DeeplinkValidityRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/DeeplinkValidityRequest.kt new file mode 100644 index 0000000000..c6586ae6c7 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/DeeplinkValidityRequest.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DeeplinkValidityRequest( + @Json(name = "link") val link: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshCustomerWalletAccessTokenRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshCustomerWalletAccessTokenRequest.kt new file mode 100644 index 0000000000..d4b509360f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/RefreshCustomerWalletAccessTokenRequest.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class RefreshCustomerWalletAccessTokenRequest( + @Json(name = "auth_type") val authType: String = "customer_wallet", + @Json(name = "refresh_token") val refreshToken: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 6814d094a9..6c60787d46 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -13,9 +13,9 @@ data class CustomerMeResponse( @Json(name = "id") val id: String, @Json(name = "state") val state: String, @Json(name = "createdAt") val createdAt: String, - @Json(name = "product_instance") val productInstance: ProductInstance, - @Json(name = "payment_account") val paymentAccount: PaymentAccount, - @Json(name = "kyc") val kyc: Kyc, + @Json(name = "product_instance") val productInstance: ProductInstance?, + @Json(name = "payment_account") val paymentAccount: PaymentAccount?, + @Json(name = "kyc") val kyc: Kyc?, ) @JsonClass(generateAdapter = true) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/DeeplinkValidityResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/DeeplinkValidityResponse.kt new file mode 100644 index 0000000000..638055ef5c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/DeeplinkValidityResponse.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DeeplinkValidityResponse( + @Json(name = "result") val result: Result?, + @Json(name = "error") val error: String?, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "status") val status: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt index d1f8d27e19..b988222b09 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayStorage.kt @@ -1,10 +1,16 @@ package com.tangem.datasource.local.visa +import com.tangem.domain.visa.model.VisaAuthTokens + interface TangemPayStorage { - suspend fun store(authHeader: String) + suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) - suspend fun get(): String? + suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens? - suspend fun clear() + suspend fun storeCustomerWalletAddress(customerWalletAddress: String) + + suspend fun getCustomerWalletAddress(): String? + + suspend fun clear(customerWalletAddress: String) } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt index 80ade6e92b..450fc7c70d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt @@ -7,6 +7,7 @@ import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource +import com.tangem.domain.visa.model.VisaAuthTokens import com.tangem.sdk.api.TangemSdkManager import javax.inject.Inject @@ -15,26 +16,35 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor( private val tangemSdkManager: TangemSdkManager, ) : TangemPayAuthDataSource { - override suspend fun generateNewAuthHeader(address: String, cardId: String): Either = either { - val challenge = visaAuthRemoteDataSource - .getCustomerWalletAuthChallenge(address) - .mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") } - .bind() + override suspend fun generateNewAuthTokens(address: String, cardId: String): Either = + either { + val challenge = visaAuthRemoteDataSource + .getCustomerWalletAuthChallenge(address) + .mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") } + .bind() - val signed = tangemSdkManager.visaCustomerWalletApprove( - VisaDataForApprove( - customerWalletCardId = cardId, - targetAddress = address, - dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge), - ), - ).toEither { IllegalStateException("TangemPay signing failed: $it") }.bind() + val signed = tangemSdkManager.visaCustomerWalletApprove( + VisaDataForApprove( + customerWalletCardId = cardId, + targetAddress = address, + dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge), + ), + ).toEither { IllegalStateException("TangemPay signing failed: $it") }.bind() - visaAuthRemoteDataSource.getTokenWithCustomerWallet( - sessionId = challenge.session.sessionId, - signature = signed.signature, - nonce = signed.dataToSign.hashToSign, + visaAuthRemoteDataSource.getTokenWithCustomerWallet( + sessionId = challenge.session.sessionId, + signature = signed.signature, + nonce = signed.dataToSign.hashToSign, + ) + .mapLeft { IllegalStateException("TangemPay token fetch failed. Error code: ${it.errorCode}") } + .bind() + } + + override suspend fun refreshAuthTokens(refreshToken: String): Either = either { + visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens( + VisaAuthTokens.RefreshToken(refreshToken, authType = VisaAuthTokens.RefreshToken.Type.CardWallet), ) - .mapLeft { IllegalStateException("TangemPay token fetch failed. Error code: ${it.errorCode}") } + .mapLeft { IllegalStateException("TangemPay token refresh failed. Error code: ${it.errorCode}") } .bind() } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index aab7f3c69d..c2d3ece8ad 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -1,9 +1,9 @@ package com.tangem.data.pay.di import com.tangem.data.pay.repository.DefaultKycRepository -import com.tangem.data.pay.usecase.DefaultKycStartInfoUseCase +import com.tangem.data.pay.repository.DefaultOnboardingRepository import com.tangem.domain.pay.repository.KycRepository -import com.tangem.domain.pay.usecase.KycStartInfoUseCase +import com.tangem.domain.pay.repository.OnboardingRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -20,5 +20,5 @@ internal interface TangemPayDataModule { @Binds @Singleton - fun bindKycStartInfoUseCase(useCase: DefaultKycStartInfoUseCase): KycStartInfoUseCase + fun bindOnboardingRepository(repository: DefaultOnboardingRepository): OnboardingRepository } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt index 20a9056985..0230e63d63 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultKycRepository.kt @@ -1,67 +1,24 @@ package com.tangem.data.pay.repository -import arrow.core.Either -import com.squareup.moshi.Moshi -import com.tangem.core.error.UniversalError -import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.pay.TangemPayApi -import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter -import com.tangem.datasource.di.NetworkMoshi -import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.pay.KycStartInfo -import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.pay.repository.KycRepository -import com.tangem.domain.visa.error.VisaApiError +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext import javax.inject.Inject -class DefaultKycRepository @Inject constructor( - @NetworkMoshi moshi: Moshi, +internal class DefaultKycRepository @Inject constructor( private val tangemPayApi: TangemPayApi, - private val authDataSource: TangemPayAuthDataSource, - private val tangemPayStorage: TangemPayStorage, + private val dispatchers: CoroutineDispatcherProvider, + private val requestHelper: TangemPayRequestPerformer, ) : KycRepository { - private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi) - - override suspend fun getKycStartInfo(address: String, cardId: String): Either { - val authHeader = authDataSource.generateNewAuthHeader(address, cardId) - .getOrNull() - .takeIf { !it.isNullOrEmpty() } - ?: return Either.Left(VisaApiError.UnknownWithoutCode) - tangemPayStorage.store(authHeader) - return getKycInfo(authHeader) - } - - override suspend fun getKycStartInfo(authHeader: String): Either { - return getKycInfo(authHeader) - } - - private suspend fun getKycInfo(authHeader: String): Either { - return request { + override suspend fun getKycStartInfo() = withContext(dispatchers.io) { + requestHelper.request { authHeader -> tangemPayApi.getKycAccess(authHeader = authHeader).getOrThrow().result }.map { KycStartInfo(token = it.token, locale = it.locale) } } - - private suspend fun request(requestBlock: suspend () -> T): Either { - return runCatching { - Either.Right(requestBlock()) - }.getOrElse { responseError -> - if (responseError is ApiResponseError.HttpException && - responseError.errorBody != null - ) { - return runCatching { - visaErrorAdapter.fromJson(responseError.errorBody!!)?.error?.code ?: responseError.code.numericCode - }.map { - Either.Left(VisaApiError.fromBackendError(it)) - }.getOrElse { - Either.Left(VisaApiError.UnknownWithoutCode) - } - } - - return Either.Left(VisaApiError.UnknownWithoutCode) - } - } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt new file mode 100644 index 0000000000..24eb2cbaa8 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -0,0 +1,40 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.core.error.UniversalError +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest +import com.tangem.domain.pay.model.CustomerInfo +import com.tangem.domain.pay.model.ProductInstance +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.visa.error.VisaApiError +import javax.inject.Inject + +private const val VALID_STATUS = "valid" + +internal class DefaultOnboardingRepository @Inject constructor( + private val tangemPayApi: TangemPayApi, + private val requestHelper: TangemPayRequestPerformer, +) : OnboardingRepository { + + override suspend fun validateDeeplink(link: String): Either = either { + return requestHelper.request { + tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link)).getOrThrow().result + ?: raise(VisaApiError.UnknownWithoutCode) + }.map { result -> result.status == VALID_STATUS } + } + + override suspend fun getCustomerInfo(): Either = either { + return requestHelper.request { authHeader -> + val response = tangemPayApi.getCustomerMe(authHeader).getOrThrow() + response.result ?: raise(VisaApiError.UnknownWithoutCode) + }.map { result -> + CustomerInfo( + productInstance = result.productInstance?.let { ProductInstance(id = it.id, status = it.status) }, + kycStatus = result.kyc?.status, + ) + } + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt new file mode 100644 index 0000000000..943480ef0e --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt @@ -0,0 +1,159 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.raise.either +import com.squareup.moshi.Moshi +import com.tangem.core.error.UniversalError +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.core.wallets.UserWalletsListRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource +import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.domain.visa.model.VisaAuthTokens +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * For TangemPay Customer Wallet auth we are using polygon address + */ +private const val POL_VALUE = "coin⟨POLYGON⟩polygon-ecosystem-token" + +internal class TangemPayRequestPerformer @Inject constructor( + @NetworkMoshi moshi: Moshi, + private val dispatchers: CoroutineDispatcherProvider, + private val tangemPayStorage: TangemPayStorage, + private val userWalletsRepository: UserWalletsListRepository, + private val getCurrencyUseCase: GetSingleCryptoCurrencyStatusUseCase, + private val authDataSource: TangemPayAuthDataSource, +) { + private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi) + + private var customerWalletAddress: String? = null + + private val refreshTokensMutex = Mutex() + private var refreshTokensJob: Deferred>? = null + + suspend fun request(requestBlock: suspend (header: String) -> T): Either = either { + withContext(dispatchers.io) { + performRequest(requestBlock = requestBlock, refreshTokens = ::refreshAuthTokens).bind() + } + } + + private suspend fun performRequest( + requestBlock: suspend (header: String) -> T, + refreshTokens: (suspend () -> Either)? = null, + ): Either = either { + runCatching { + requestBlock("Bearer ${getAccessTokens().bind().accessToken}") + }.getOrElse { error -> + when (error) { + is ApiResponseError.HttpException -> { + if (refreshTokens != null && error.code == ApiResponseError.HttpException.Code.UNAUTHORIZED) { + refreshOrJoin(refreshTokens).bind() + performRequest(requestBlock, refreshTokens = null).bind() + } else { + raise(mapHttpError(error)) + } + } + else -> raise(VisaApiError.UnknownWithoutCode) + } + } + } + + private suspend fun refreshOrJoin( + refreshTokens: suspend () -> Either, + ): Either { + val jobToAwait: Deferred> = + refreshTokensMutex.withLock { + val current = refreshTokensJob + if (current == null || current.isCompleted) { + coroutineScope { + async { refreshTokens() }.also { refreshTokensJob = it } + } + } else { + current + } + } + val result = try { + jobToAwait.await() + } catch (ignore: Throwable) { + Either.Left(VisaApiError.UnknownWithoutCode) + } finally { + refreshTokensMutex.withLock { + if (refreshTokensJob === jobToAwait && jobToAwait.isCompleted) { + refreshTokensJob = null + } + } + } + return result + } + + private suspend fun getCustomerWalletAddress(): Either = either { + customerWalletAddress + ?: tangemPayStorage.getCustomerWalletAddress() + ?: fetchAuthInputData().bind().address + } + + private suspend fun getAccessTokens(): Either = either { + val address = getCustomerWalletAddress().bind() + tangemPayStorage.getAuthTokens(address) ?: fetchTokens().bind() + } + + private fun mapHttpError(throwable: ApiResponseError.HttpException): UniversalError { + val errorBody = throwable.errorBody ?: return VisaApiError.UnknownWithoutCode + return runCatching { + visaErrorAdapter.fromJson(errorBody)?.error?.code ?: throwable.code.numericCode + }.map { + VisaApiError.fromBackendError(it) + }.getOrElse { + VisaApiError.UnknownWithoutCode + } + } + + private suspend fun fetchAuthInputData(): Either = either { + val wallet = userWalletsRepository.userWalletsSync().find { it is UserWallet.Cold } as? UserWallet.Cold + ?: raise(VisaApiError.UnknownWithoutCode) + + val address = getCurrencyUseCase.invokeMultiWalletSync(wallet.walletId, CryptoCurrency.ID.fromValue(POL_VALUE)) + .getOrNull()?.value?.networkAddress?.defaultAddress?.value ?: raise(VisaApiError.UnknownWithoutCode) + + customerWalletAddress = address + tangemPayStorage.storeCustomerWalletAddress(address) + + AuthInputData(address, wallet.cardId) + } + + private suspend fun fetchTokens(): Either = either { + val inputData = fetchAuthInputData().bind() + val tokens = authDataSource.generateNewAuthTokens(inputData.address, inputData.cardId) + .getOrNull() + ?: return Either.Left(VisaApiError.UnknownWithoutCode) + tangemPayStorage.storeAuthTokens(inputData.address, tokens) + tokens + } + + private suspend fun refreshAuthTokens(): Either = either { + val customerWalletAddress = getCustomerWalletAddress().bind() + val refreshToken = getAccessTokens().bind().refreshToken.value + val tokens = authDataSource.refreshAuthTokens(refreshToken).getOrNull() + ?: raise(VisaApiError.UnknownWithoutCode) + tangemPayStorage.storeAuthTokens(customerWalletAddress, tokens) + tokens + } +} + +internal data class AuthInputData( + val address: String, + val cardId: String, +) \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultKycStartInfoUseCase.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultKycStartInfoUseCase.kt deleted file mode 100644 index 1ee376e005..0000000000 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/usecase/DefaultKycStartInfoUseCase.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.data.pay.usecase - -import arrow.core.Either -import arrow.core.raise.either -import com.tangem.core.error.UniversalError -import com.tangem.domain.core.wallets.UserWalletsListRepository -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.pay.KycStartInfo -import com.tangem.domain.pay.repository.KycRepository -import com.tangem.domain.pay.usecase.KycStartInfoUseCase -import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase -import com.tangem.domain.visa.error.VisaApiError -import javax.inject.Inject - -/** - * For TangemPay Customer Wallet auth we are using polygon address - */ -private const val POL_VALUE = "coin⟨POLYGON⟩polygon-ecosystem-token" - -internal class DefaultKycStartInfoUseCase @Inject constructor( - private val userWalletsRepository: UserWalletsListRepository, - private val getCurrencyUseCase: GetSingleCryptoCurrencyStatusUseCase, - private val kycRepository: KycRepository, -) : KycStartInfoUseCase { - - override suspend fun invoke(): Either = either { - val wallet = userWalletsRepository.userWalletsSync().find { it is UserWallet.Cold } as? UserWallet.Cold - ?: raise(VisaApiError.UnknownWithoutCode) - - val address = getCurrencyUseCase.invokeMultiWalletSync(wallet.walletId, CryptoCurrency.ID.fromValue(POL_VALUE)) - .getOrNull()?.value?.networkAddress?.defaultAddress?.value ?: raise(VisaApiError.UnknownWithoutCode) - - kycRepository.getKycStartInfo(address, wallet.cardId).bind() - } -} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt index e396978597..baadf36595 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaAuthRemoteDataSource.kt @@ -83,7 +83,7 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor( sessionId: String, signature: String, nonce: String, - ): Either = withContext(dispatchers.io) { + ): Either = withContext(dispatchers.io) { request { visaAuthApi.getTokenByCustomerWallet( GetTokenByCustomerWalletRequest( @@ -93,7 +93,28 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor( ), ).getOrThrow() }.map { response -> - "Bearer ${response.result.accessToken}" + VisaAuthTokens( + response.result.accessToken, + VisaAuthTokens.RefreshToken( + response.result.refreshToken, + VisaAuthTokens.RefreshToken.Type.CardWallet, + ), + ) + } + } + + override suspend fun refreshCustomerWalletAuthTokens( + refreshToken: VisaAuthTokens.RefreshToken, + ): Either = withContext(dispatchers.io) { + request { + visaAuthApi.refreshCustomerWalletAccessToken( + RefreshCustomerWalletAccessTokenRequest(refreshToken = refreshToken.value), + ).getOrThrow() + }.map { response -> + VisaAuthTokens( + accessToken = response.result.accessToken, + refreshToken = refreshToken.copy(value = response.result.refreshToken), + ) } } @@ -157,24 +178,25 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor( } } - override suspend fun exchangeAccessToken(tokens: VisaAuthTokens): Either { - return request { - visaAuthApi.exchangeAccessToken( - ExchangeAccessTokenRequest( - accessToken = tokens.accessToken, - refreshToken = tokens.refreshToken.value, - ), - ).getOrThrow() - }.map { response -> - VisaAuthTokens( - accessToken = response.result.accessToken, - refreshToken = VisaAuthTokens.RefreshToken( - value = response.result.refreshToken, - authType = VisaAuthTokens.RefreshToken.Type.CardWallet, - ), - ) + override suspend fun exchangeAccessToken(tokens: VisaAuthTokens): Either = + withContext(dispatchers.io) { + request { + visaAuthApi.exchangeAccessToken( + ExchangeAccessTokenRequest( + accessToken = tokens.accessToken, + refreshToken = tokens.refreshToken.value, + ), + ).getOrThrow() + }.map { response -> + VisaAuthTokens( + accessToken = response.result.accessToken, + refreshToken = VisaAuthTokens.RefreshToken( + value = response.result.refreshToken, + authType = VisaAuthTokens.RefreshToken.Type.CardWallet, + ), + ) + } } - } private suspend fun request(requestBlock: suspend () -> T): Either { return runCatching { diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt index 323d779515..05725f0183 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt @@ -1,8 +1,11 @@ package com.tangem.domain.pay.datasource import arrow.core.Either +import com.tangem.domain.visa.model.VisaAuthTokens interface TangemPayAuthDataSource { - suspend fun generateNewAuthHeader(address: String, cardId: String): Either + suspend fun generateNewAuthTokens(address: String, cardId: String): Either + + suspend fun refreshAuthTokens(refreshToken: String): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt new file mode 100644 index 0000000000..b018ac10da --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.pay.model + +private const val APPROVED_KYC_STATUS = "APPROVED" +private const val ACTIVE_PI_STATUS = "active" + +data class CustomerInfo( + val productInstance: ProductInstance?, + val kycStatus: String?, +) { + + fun isKycApproved() = kycStatus == APPROVED_KYC_STATUS + + fun isProductInstanceActive() = productInstance?.status == ACTIVE_PI_STATUS +} + +data class ProductInstance( + val id: String, + val status: String, +) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt index 675923877b..01d3a268d7 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/KycRepository.kt @@ -7,12 +7,7 @@ import com.tangem.domain.pay.KycStartInfo interface KycRepository { /** - * Returns fresh KYC data to start the survey. Used only for first time launch + * Returns KYC data to start or continue the survey */ - suspend fun getKycStartInfo(address: String, cardId: String): Either - - /** - * Returns KYC data to continue the survey. Used when KYC wasn't finished by the user - */ - suspend fun getKycStartInfo(authHeader: String): Either + suspend fun getKycStartInfo(): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt new file mode 100644 index 0000000000..4838f46c53 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.pay.repository + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.pay.model.CustomerInfo + +interface OnboardingRepository { + + suspend fun validateDeeplink(link: String): Either + + suspend fun getCustomerInfo(): Either +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/KycStartInfoUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/KycStartInfoUseCase.kt deleted file mode 100644 index 8736279c57..0000000000 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/KycStartInfoUseCase.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.domain.pay.usecase - -import arrow.core.Either -import com.tangem.core.error.UniversalError -import com.tangem.domain.pay.KycStartInfo - -interface KycStartInfoUseCase { - - suspend operator fun invoke(): Either -} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt b/domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt index ebec77de6b..251532eeea 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/visa/datasource/VisaAuthRemoteDataSource.kt @@ -26,7 +26,11 @@ interface VisaAuthRemoteDataSource { sessionId: String, signature: String, nonce: String, - ): Either + ): Either + + suspend fun refreshCustomerWalletAuthTokens( + refreshToken: VisaAuthTokens.RefreshToken, + ): Either suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): Either diff --git a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt index 596b46c3ac..1be34aa554 100644 --- a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt +++ b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycComponent.kt @@ -29,7 +29,7 @@ class DefaultKycComponent @AssistedInject constructor( .withAccessToken(accessToken = startInfo.token, onTokenExpiration = tokenExpirationHandler) .withTheme(TangemSNSTheme.theme(activity)) .withIconHandler(TangemSNSIconHandler()) - .withLocale(Locale("en")) + .withLocale(Locale(startInfo.locale)) .build() snsSdk.launch() } diff --git a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt index c46ad85577..21b8e8e9bb 100644 --- a/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt +++ b/features/kyc/impl/src/main/kotlin/com/tangem/features/kyc/DefaultKycModel.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Stable import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.domain.pay.KycStartInfo -import com.tangem.domain.pay.usecase.KycStartInfoUseCase +import com.tangem.domain.pay.repository.KycRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -15,7 +15,7 @@ import javax.inject.Inject @ModelScoped class DefaultKycModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - private val kycStartInfoUseCase: KycStartInfoUseCase, + private val kycRepository: KycRepository, ) : Model() { private val _uiState: MutableStateFlow = MutableStateFlow(null) @@ -23,7 +23,7 @@ class DefaultKycModel @Inject constructor( fun getKycToken() { modelScope.launch { - kycStartInfoUseCase().getOrNull()?.let { _uiState.emit(it) } + kycRepository.getKycStartInfo().getOrNull()?.let { _uiState.emit(it) } } } } \ No newline at end of file diff --git a/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/MockKycComponent.kt b/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/MockKycComponent.kt index 2f1b4ef303..71bd1f9b5d 100644 --- a/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/MockKycComponent.kt +++ b/features/kyc/mock/src/main/kotlin/com/tangem/features/kyc/MockKycComponent.kt @@ -8,7 +8,7 @@ import dagger.assisted.AssistedInject /** * Mocking it for release/external builds to exclude SumSub dependency */ -class MockKycComponent @AssistedInject constructor( +internal class MockKycComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, ) : KycComponent, AppComponentContext by appComponentContext {