Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-27 10:36:54 +03:00
commit 7c5777974a
25 changed files with 418 additions and 159 deletions

View file

@ -269,8 +269,8 @@ dependencies {
debugImplementation(projects.features.kyc.impl)
internalImplementation(projects.features.kyc.impl)
mockedImplementation(projects.features.kyc.impl)
releaseImplementation(projects.features.kyc.mock)
externalImplementation(projects.features.kyc.mock)
releaseImplementation(projects.features.kyc.impl)
externalImplementation(projects.features.kyc.impl)
implementation(projects.features.welcome.api)
implementation(projects.features.welcome.impl)
implementation(projects.features.createWalletSelection.api)

View file

@ -2,8 +2,9 @@ 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.di.NetworkMoshi
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.sdk.storage.AndroidSecureStorageV2
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -11,7 +12,6 @@ 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 ORDER_ID_KEY = "tangem_pay_order_id_key"
@ -19,6 +19,7 @@ private const val ORDER_ID_KEY = "tangem_pay_order_id_key"
@Singleton
internal class DefaultTangemPayStorage @Inject constructor(
@ApplicationContext applicationContext: Context,
@NetworkMoshi moshi: Moshi,
private val dispatcherProvider: CoroutineDispatcherProvider,
) : TangemPayStorage {
@ -29,14 +30,21 @@ internal class DefaultTangemPayStorage @Inject constructor(
name = "tangem_pay_storage",
)
}
private val moshi by lazy {
Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
}
private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) }
override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) {
withContext(dispatcherProvider.io) {
secureStorage.store(key = createCustomerAddressKey(userWalletId), value = customerWalletAddress)
}
}
override suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? {
return withContext(dispatcherProvider.io) {
secureStorage.getAsString(createCustomerAddressKey(userWalletId))
}
}
override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) =
withContext(dispatcherProvider.io) {
val json = tokensAdapter.toJson(tokens)
@ -71,10 +79,14 @@ internal class DefaultTangemPayStorage @Inject constructor(
secureStorage.delete(createOrderIdKey(customerWalletAddress))
}
override suspend fun clearAll(customerWalletAddress: String) = withContext(dispatcherProvider.io) {
secureStorage.delete(createKey(customerWalletAddress))
secureStorage.delete(createOrderIdKey(customerWalletAddress))
}
override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) =
withContext(dispatcherProvider.io) {
secureStorage.delete(createCustomerAddressKey(userWalletId))
secureStorage.delete(createKey(customerWalletAddress))
secureStorage.delete(createOrderIdKey(customerWalletAddress))
}
private fun createCustomerAddressKey(userWalletId: UserWalletId): String = userWalletId.stringValue
private fun createKey(address: String): String = "${DEFAULT_KEY}_$address"

View file

@ -7,6 +7,7 @@ import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import dagger.Module
@ -27,6 +28,7 @@ internal class TangemSdkManagerModule {
cardSdkConfigRepository: CardSdkConfigRepository,
visaCardScanHandler: VisaCardScanHandler,
visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
): TangemSdkManager {
return if (BuildConfig.MOCK_DATA_SOURCE) {
@ -37,6 +39,7 @@ internal class TangemSdkManagerModule {
resources = context.resources,
visaCardScanHandler = visaCardScanHandler,
visaCardActivationTaskFactory = visaCardActivationTaskFactory,
tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
)
}

View file

@ -18,13 +18,17 @@ import com.tangem.core.res.getStringSafe
import com.tangem.crypto.bip39.DefaultMnemonic
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VisaActivationInput
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
import com.tangem.domain.visa.model.sign
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.operations.ScanTask
import com.tangem.operations.derivation.DerivationTaskResponse
@ -44,6 +48,7 @@ import com.tangem.tap.domain.tasks.product.CreateProductWalletTask
import com.tangem.tap.domain.tasks.product.ResetBackupCardTask
import com.tangem.tap.domain.tasks.product.ResetToFactorySettingsTask
import com.tangem.tap.domain.tasks.product.ScanProductTask
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask
import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask
@ -62,6 +67,7 @@ internal class DefaultTangemSdkManager(
private val resources: Resources,
private val visaCardScanHandler: VisaCardScanHandler,
private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
) : TangemSdkManager {
@ -511,6 +517,18 @@ internal class DefaultTangemSdkManager(
)
}
override suspend fun tangemPayProduceInitialCredentials(
cardId: String,
): CompletionResult<TangemPayInitialCredentials> {
return coroutineScope {
runTaskAsyncReturnOnMain(
runnable = tangemPayChallengeTaskFactory.create(coroutineScope = this),
cardId = cardId,
initialMessage = Message(resources.getStringSafe(R.string.initial_message_tap_header)),
)
}
}
// endregion
companion object {

View file

@ -213,5 +213,11 @@ class MockTangemSdkManager(
error("Not implemented")
}
override suspend fun tangemPayProduceInitialCredentials(
cardId: String,
): CompletionResult<TangemPayInitialCredentials> {
error("Not implemented")
}
// endregion
}

View file

@ -0,0 +1,130 @@
package com.tangem.tap.domain.tasks.visa
import arrow.core.getOrElse
import com.tangem.common.CompletionResult
import com.tangem.common.card.CardWallet
import com.tangem.common.card.EllipticCurve
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.CompletionCallback
import com.tangem.common.core.TangemSdkError
import com.tangem.core.error.ext.tangemError
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.error.VisaActivationError
import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
import com.tangem.domain.visa.model.sign
import com.tangem.operations.derivation.DeriveWalletPublicKeyTask
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class TangemPayGenerateAddressAndSignChallengeTask @AssistedInject constructor(
@Assisted private val coroutineScope: CoroutineScope,
private val dispatchersProvider: CoroutineDispatcherProvider,
private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource,
) : CardSessionRunnable<TangemPayInitialCredentials> {
override fun run(session: CardSession, callback: CompletionCallback<TangemPayInitialCredentials>) {
coroutineScope.launch {
callback(runSuspend(session = session))
}
}
private suspend fun runSuspend(session: CardSession): CompletionResult<TangemPayInitialCredentials> {
val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val wallet = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 }
?: return CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError)
val derivationResult = runDerivationTask(session, wallet)
val address = when (derivationResult) {
is CompletionResult.Failure<*> -> return CompletionResult.Failure(derivationResult.error)
is CompletionResult.Success<ExtendedPublicKey> -> generateAddressFromExtendedKey(derivationResult.data)
}
val challenge = withContext(dispatchersProvider.io) {
visaAuthRemoteDataSource.getCustomerWalletAuthChallenge(address)
}.getOrElse { return CompletionResult.Failure(it.tangemError) }
val dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge)
val approveResult = runVisaCustomerWalletApproveTask(
session = session,
cardId = card.cardId,
targetAddress = address,
dataToSign = dataToSign,
)
val signedData = when (approveResult) {
is CompletionResult.Failure<*> -> return CompletionResult.Failure(approveResult.error)
is CompletionResult.Success<VisaSignedDataByCustomerWallet> -> approveResult.data
}
val authTokens = withContext(dispatchersProvider.io) {
visaAuthRemoteDataSource.getTokenWithCustomerWallet(
sessionId = challenge.session.sessionId,
signature = signedData.signature,
nonce = signedData.dataToSign.hashToSign,
)
}.getOrNull() ?: return CompletionResult.Failure(VisaActivationError.FailedRemoteState.tangemError)
return CompletionResult.Success(
data = TangemPayInitialCredentials(
customerWalletAddress = address,
authTokens = authTokens,
),
)
}
private suspend fun runDerivationTask(
session: CardSession,
wallet: CardWallet,
): CompletionResult<ExtendedPublicKey> {
val deferred = CompletableDeferred<CompletionResult<ExtendedPublicKey>>()
val derivationTask = DeriveWalletPublicKeyTask(
walletPublicKey = wallet.publicKey,
derivationPath = VisaUtilities.customDerivationPath,
)
derivationTask.run(session = session, callback = deferred::complete)
return deferred.await()
}
private suspend fun runVisaCustomerWalletApproveTask(
session: CardSession,
cardId: String,
targetAddress: String,
dataToSign: VisaDataToSignByCustomerWallet,
): CompletionResult<VisaSignedDataByCustomerWallet> {
val deferred = CompletableDeferred<CompletionResult<VisaSignedDataByCustomerWallet>>()
val task = VisaCustomerWalletApproveTask(
visaDataForApprove = VisaCustomerWalletApproveTask.Input(
cardId = cardId,
targetAddress = targetAddress,
hashToSign = dataToSign.hashToSign,
sign = dataToSign::sign,
),
)
task.run(session = session, callback = deferred::complete)
return deferred.await()
}
private fun generateAddressFromExtendedKey(extendedPublicKey: ExtendedPublicKey): String {
val derivationData = VisaUtilities.visaBlockchain.makeAddressesFromExtendedPublicKey(
extendedPublicKey = extendedPublicKey,
cachedIndex = null,
)
return derivationData.address
}
@AssistedFactory
interface Factory {
fun create(coroutineScope: CoroutineScope): TangemPayGenerateAddressAndSignChallengeTask
}
}

View file

@ -16,7 +16,6 @@ import com.tangem.common.extensions.toHexString
import com.tangem.core.error.ext.tangemError
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.card.common.visa.VisaWalletPublicKeyUtility
import com.tangem.domain.card.common.visa.VisaWalletPublicKeyUtility.findKeyWithoutDerivation
@ -59,21 +58,7 @@ class VisaCustomerWalletApproveTask(
session: CardSession,
callback: CompletionCallback<VisaSignedDataByCustomerWallet>,
) {
val cardDTO = CardDTO(card)
val derivationStyle = cardDTO.derivationStyleProvider.getDerivationStyle() ?: run {
proceedApproveWithLegacyCard(
card = card,
session = session,
callback = callback,
)
return
}
val derivationPath = VisaUtilities.visaDefaultDerivationPath(derivationStyle) ?: run {
callback(CompletionResult.Failure(VisaActivationError.FailedToCreateAddress.tangemError))
return
}
val derivationPath = VisaUtilities.customDerivationPath
val wallet = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } ?: run {
callback(CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError))

View file

@ -33,7 +33,7 @@
},
{
"name": "TANGEM_PAY_ENABLED",
"version": "undefined"
"version": "5.30.0"
},
{
"name": "NEW_TOKEN_RECEIVE_ENABLED",

View file

@ -1,9 +1,13 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.model.VisaAuthTokens
interface TangemPayStorage {
suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String)
suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String?
suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens)
suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens?
@ -14,5 +18,5 @@ interface TangemPayStorage {
suspend fun clearOrderId(customerWalletAddress: String)
suspend fun clearAll(customerWalletAddress: String)
suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String)
}

View file

@ -4,9 +4,8 @@ import arrow.core.Either
import arrow.core.raise.either
import com.tangem.common.CompletionResult
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.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.sdk.api.TangemSdkManager
import javax.inject.Inject
@ -16,29 +15,14 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor(
private val tangemSdkManager: TangemSdkManager,
) : TangemPayAuthDataSource {
override suspend fun generateNewAuthTokens(address: String, cardId: String): Either<Throwable, VisaAuthTokens> =
either {
val challenge = visaAuthRemoteDataSource
.getCustomerWalletAuthChallenge(address)
.mapLeft { IllegalStateException("TangemPay challenge failed. Error code: ${it.errorCode}") }
.bind()
override suspend fun produceInitialCredentials(cardId: String): Either<Throwable, TangemPayInitialCredentials> {
val initialCredentials = tangemSdkManager.tangemPayProduceInitialCredentials(cardId = cardId)
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,
)
.mapLeft { IllegalStateException("TangemPay token fetch failed. Error code: ${it.errorCode}") }
.bind()
return when (initialCredentials) {
is CompletionResult.Failure<*> -> Either.Left(initialCredentials.error)
is CompletionResult.Success<TangemPayInitialCredentials> -> Either.Right(initialCredentials.data)
}
}
override suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, VisaAuthTokens> = either {
visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens(
@ -47,9 +31,4 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor(
.mapLeft { IllegalStateException("TangemPay token refresh failed. Error code: ${it.errorCode}") }
.bind()
}
}
private fun <T> CompletionResult<T>.toEither(map: (Throwable) -> Throwable) = when (this) {
is CompletionResult.Success -> Either.Right(data)
is CompletionResult.Failure -> Either.Left(map(error))
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.pay.DataForReceiveFactory
import com.tangem.domain.pay.repository.CardDetailsRepository
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.TangemPayIssueOrderUseCase
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
@ -52,6 +53,14 @@ internal interface TangemPayDataModule {
return TangemPayMainScreenCustomerInfoUseCase(repository = repository)
}
@Provides
@Singleton
fun provideProduceTangemPayInitialDataUseCase(
repository: OnboardingRepository,
): ProduceTangemPayInitialDataUseCase {
return ProduceTangemPayInitialDataUseCase(repository = repository)
}
@Provides
@Singleton
fun provideTangemPayIssueOrderUseCase(repository: OnboardingRepository): TangemPayIssueOrderUseCase {

View file

@ -2,11 +2,14 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.data.pay.util.TangemPayWalletsManager
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.datasource.api.pay.models.request.OrderRequest
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
@ -26,17 +29,44 @@ internal class DefaultOnboardingRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayStorage: TangemPayStorage,
private val authDataSource: TangemPayAuthDataSource,
private val tangemPayWalletsManager: TangemPayWalletsManager,
) : OnboardingRepository {
override suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request {
tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link))
}.result
val result = tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link))
.getOrThrow()
.result
result?.status == VALID_STATUS
}
}
override suspend fun isTangemPayInitialDataProduced(): Boolean {
val walletId = tangemPayWalletsManager.getDefaultWalletForTangemPay().walletId
val customerWalletAddress = tangemPayStorage.getCustomerWalletAddress(walletId) ?: return false
tangemPayStorage.getAuthTokens(customerWalletAddress) ?: return false
return true
}
override suspend fun produceInitialData() {
val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPay()
val initialCredentials = authDataSource.produceInitialCredentials(cardId = wallet.cardId)
.fold(
ifLeft = { error -> error("Can not produce initial data: ${error.message}") },
ifRight = { it },
)
tangemPayStorage.storeCustomerWalletAddress(
userWalletId = wallet.walletId,
customerWalletAddress = initialCredentials.customerWalletAddress,
)
tangemPayStorage.storeAuthTokens(
customerWalletAddress = initialCredentials.customerWalletAddress,
tokens = initialCredentials.authTokens,
)
}
override suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request { authHeader ->
@ -84,7 +114,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
override suspend fun createOrder(): Either<UniversalError, Unit> = withContext(dispatcherProvider.io) {
requestHelper.runWithErrorLogs(TAG) {
val walletAddress = requestHelper.getCustomerWalletAddress()
val result = requestHelper.requestWithPersistedToken { authHeader ->
val result = requestHelper.request { authHeader ->
tangemPayApi.createOrder(authHeader, body = OrderRequest(walletAddress))
}.result ?: error("Create order result is null")
@ -128,7 +158,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
private suspend fun getCustomerInfoWithPersistedToken(): CustomerInfo {
val result = requestHelper.requestWithPersistedToken { authHeader ->
val result = requestHelper.request { authHeader ->
tangemPayApi.getCustomerMe(authHeader)
}.result
return getCustomerInfo(result)

View file

@ -2,9 +2,7 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.error.UniversalError
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.pay.util.TangemPayErrorConverter
import com.tangem.data.pay.util.TangemPayWalletsManager
import com.tangem.datasource.api.common.response.ApiResponse
@ -14,10 +12,10 @@ import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.visa.model.getAuthHeader
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
@ -30,11 +28,9 @@ internal class TangemPayRequestPerformer @Inject constructor(
private val tangemPayStorage: TangemPayStorage,
private val authDataSource: TangemPayAuthDataSource,
private val tangemPayWalletsManager: TangemPayWalletsManager,
private val walletManagersFacade: WalletManagersFacade,
private val networkFactory: NetworkFactory,
) {
private var customerWalletAddress: String? = null
private val customerWalletAddress = MutableStateFlow<String?>(null)
private val refreshTokensMutex = Mutex()
private var refreshTokensJob: Deferred<VisaAuthTokens>? = null
@ -67,21 +63,14 @@ internal class TangemPayRequestPerformer @Inject constructor(
)
}
suspend fun <T : Any> requestWithPersistedToken(requestBlock: suspend (header: String) -> ApiResponse<T>): T =
withContext(dispatchers.io) {
performRequest(
requestBlock = requestBlock,
getTokens = { getAccessTokensIfSaved() ?: error("Cannot get saved access tokens") },
refreshTokens = ::refreshAuthTokens,
)
}
private suspend fun <T : Any> performRequest(
requestBlock: suspend (header: String) -> ApiResponse<T>,
getTokens: (suspend () -> VisaAuthTokens),
refreshTokens: (suspend () -> VisaAuthTokens)? = null,
): T = runCatching {
requestBlock("Bearer ${getTokens().accessToken}").getOrThrow()
val tokens = getTokens()
val header = tokens.getAuthHeader()
requestBlock(header).getOrThrow()
}.getOrElse { error ->
val unauthorizedCode = ApiResponseError.HttpException.Code.UNAUTHORIZED
if (error is ApiResponseError.HttpException && refreshTokens != null && error.code == unauthorizedCode) {
@ -116,52 +105,34 @@ internal class TangemPayRequestPerformer @Inject constructor(
return result
}
suspend fun getCustomerWalletAddress(): String = customerWalletAddress ?: fetchAuthInputData().address
suspend fun getCustomerWalletAddress(): String {
val existingAddress = customerWalletAddress.value
if (existingAddress != null) {
return existingAddress
}
val storedAddress = tangemPayStorage.getCustomerWalletAddress(
userWalletId = tangemPayWalletsManager.getDefaultWalletForTangemPay().walletId,
) ?: error("Can not find customer address")
customerWalletAddress.value = storedAddress
return storedAddress
}
private suspend fun getAccessTokens(): VisaAuthTokens {
return getAccessTokensIfSaved() ?: fetchTokens()
}
private suspend fun getAccessTokensIfSaved(): VisaAuthTokens? {
return tangemPayStorage.getAuthTokens(getCustomerWalletAddress())
}
private suspend fun fetchAuthInputData(): AuthInputData {
val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPay()
val network = networkFactory.create(
blockchain = Blockchain.Polygon,
extraDerivationPath = null,
derivationStyleProvider = wallet.derivationStyleProvider,
canHandleTokens = true,
) ?: error("Cannot create network")
val address = walletManagersFacade.getDefaultAddress(wallet.walletId, network)
?: error("Cannot get polygon address")
customerWalletAddress = address
return AuthInputData(address, wallet.cardId)
}
private suspend fun fetchTokens(): VisaAuthTokens {
val inputData = fetchAuthInputData()
val tokens = authDataSource.generateNewAuthTokens(inputData.address, inputData.cardId)
.getOrNull() ?: error("Cannot fetch tokens")
tangemPayStorage.storeAuthTokens(inputData.address, tokens)
val walletAddress = getCustomerWalletAddress()
val tokens = tangemPayStorage.getAuthTokens(walletAddress) ?: error("Auth tokens are not stored")
return tokens
}
private suspend fun refreshAuthTokens(): VisaAuthTokens {
val customerWalletAddress = getCustomerWalletAddress()
val refreshToken = getAccessTokens().refreshToken.value
val tokens = authDataSource.refreshAuthTokens(refreshToken).getOrNull() ?: error("Cannot refresh tokens")
val tokens = authDataSource.refreshAuthTokens(refreshToken)
.fold(
ifLeft = { error -> error("Cannot refresh tokens: ${error.message}") },
ifRight = { it },
)
tangemPayStorage.storeAuthTokens(customerWalletAddress, tokens)
return tokens
}
}
internal data class AuthInputData(
val address: String,
val cardId: String,
)
}

View file

@ -3,6 +3,7 @@ package com.tangem.domain.card.common.visa
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.common.card.FirmwareVersion
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.models.scan.CardDTO
private const val VISA_BATCH_START = "AE"
@ -16,6 +17,7 @@ object VisaUtilities {
val visaDefaultDerivationPath
get() = visaBlockchain.derivationPath(DerivationStyle.V3)
val customDerivationPath = DerivationPath("m/44'/60'/999999'/0/0")
fun visaDefaultDerivationPath(style: DerivationStyle) = visaBlockchain.derivationPath(style)

View file

@ -0,0 +1,3 @@
package com.tangem.domain.visa.model
data class TangemPayInitialCredentials(val customerWalletAddress: String, val authTokens: VisaAuthTokens)

View file

@ -1,11 +1,12 @@
package com.tangem.domain.pay.datasource
import arrow.core.Either
import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VisaAuthTokens
interface TangemPayAuthDataSource {
suspend fun generateNewAuthTokens(address: String, cardId: String): Either<Throwable, VisaAuthTokens>
suspend fun produceInitialCredentials(cardId: String): Either<Throwable, TangemPayInitialCredentials>
suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, VisaAuthTokens>
}

View file

@ -9,6 +9,10 @@ interface OnboardingRepository {
suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean>
suspend fun isTangemPayInitialDataProduced(): Boolean
suspend fun produceInitialData()
suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo>
suspend fun createOrder(): Either<UniversalError, Unit>

View file

@ -0,0 +1,21 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import arrow.core.Either.Companion.catch
import com.tangem.domain.pay.repository.OnboardingRepository
class ProduceTangemPayInitialDataUseCase(
private val repository: OnboardingRepository,
) {
suspend operator fun invoke(): Either<Throwable, Unit> {
return catch {
val isDataProduced = repository.isTangemPayInitialDataProduced()
if (isDataProduced) {
return@catch Unit
} else {
repository.produceInitialData()
}
}
}
}

View file

@ -39,4 +39,7 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.timber)
}

View file

@ -21,13 +21,8 @@ internal class DefaultTangemPayOnboardingComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val state by model.screenState.collectAsStateWithLifecycle()
TandemPayOnboardingScreen(
modifier = modifier,
state = state,
onBackClick = model::back,
onOpenKycClick = model::openKyc,
)
val state by model.uiState.collectAsStateWithLifecycle()
TandemPayOnboardingScreen(modifier = modifier, state = state)
}
@AssistedFactory

View file

@ -7,12 +7,15 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Stable
@ -22,12 +25,12 @@ internal class TangemPayOnboardingModel @Inject constructor(
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
private val repository: OnboardingRepository,
private val produceInitialDataUseCase: ProduceTangemPayInitialDataUseCase,
) : Model() {
private val params = paramsContainer.require<TangemPayOnboardingComponent.Params>()
val screenState: StateFlow<TangemPayOnboardingScreenState>
field = MutableStateFlow(TangemPayOnboardingScreenState())
val uiState: StateFlow<TangemPayOnboardingScreenState>
field = MutableStateFlow(getInitialState())
init {
modelScope.launch {
@ -37,19 +40,15 @@ internal class TangemPayOnboardingModel @Inject constructor(
}
is TangemPayOnboardingComponent.Params.Deeplink -> {
repository.validateDeeplink(params.deeplink)
.onRight { isValid -> if (isValid) checkCustomerInfo() }
.onRight { isValid -> if (isValid) showOnboarding() }
.onLeft { back() }
}
}
}
}
fun openKyc() {
router.replaceAll(AppRoute.Wallet, AppRoute.Kyc)
}
fun back() {
router.pop()
private fun showOnboarding() {
uiState.update { it.copy(fullScreenLoading = false) }
}
private suspend fun checkCustomerInfo() {
@ -59,7 +58,7 @@ internal class TangemPayOnboardingModel @Inject constructor(
!customerInfo.isKycApproved -> {
when (params) {
is TangemPayOnboardingComponent.Params.Deeplink ->
screenState.value = screenState.value.copy(fullScreenLoading = false)
uiState.value = uiState.value.copy(fullScreenLoading = false)
else -> openKyc()
}
}
@ -68,4 +67,48 @@ internal class TangemPayOnboardingModel @Inject constructor(
}
.onLeft { back() }
}
private fun onGetCardClick() {
uiState.update { it.copy(buttonLoading = true) }
modelScope.launch {
val result = produceInitialDataUseCase()
if (result.isLeft()) {
Timber.e("Error producing initial data: ${result.leftOrNull()?.message}")
uiState.update { it.copy(buttonLoading = false) }
return@launch
}
repository.getCustomerInfo()
.fold(
ifLeft = {
Timber.e("Error getCustomerInfo: ${it.errorCode}")
uiState.update { it.copy(buttonLoading = false) }
},
ifRight = { customerInfo ->
if (customerInfo.isKycApproved) {
back()
} else {
openKyc()
}
},
)
}
}
private fun openKyc() {
router.replaceAll(AppRoute.Wallet, AppRoute.Kyc)
}
private fun back() {
router.pop()
}
private fun getInitialState(): TangemPayOnboardingScreenState {
return TangemPayOnboardingScreenState(
fullScreenLoading = true,
buttonLoading = false,
onGetCardClick = ::onGetCardClick,
onBackClick = ::back,
)
}
}

View file

@ -9,24 +9,21 @@ import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.WindowInsetsZero
@Composable
internal fun TandemPayOnboardingScreen(
state: TangemPayOnboardingScreenState,
onOpenKycClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
internal fun TandemPayOnboardingScreen(state: TangemPayOnboardingScreenState, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier.systemBarsPadding(),
topBar = {
AppBarWithBackButton(
modifier = Modifier.statusBarsPadding(),
onBackClick = onBackClick,
onBackClick = state.onBackClick,
iconRes = R.drawable.ic_back_24,
)
},
@ -37,7 +34,7 @@ internal fun TandemPayOnboardingScreen(
.padding(paddingValues)
.fillMaxSize(),
state = state,
onButtonClick = onOpenKycClick,
onButtonClick = state.onGetCardClick,
)
},
)
@ -46,8 +43,35 @@ internal fun TandemPayOnboardingScreen(
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewDarkTheme() {
private fun PreviewDarkTheme(
@PreviewParameter(TangemPayOnboardingScreenStateProvider::class)
state: TangemPayOnboardingScreenState,
) {
TangemThemePreview {
TandemPayOnboardingScreen(state = TangemPayOnboardingScreenState(), {}, {})
TandemPayOnboardingScreen(state = state, modifier = Modifier.fillMaxSize())
}
}
}
private class TangemPayOnboardingScreenStateProvider :
CollectionPreviewParameterProvider<TangemPayOnboardingScreenState>(
listOf(
TangemPayOnboardingScreenState(
fullScreenLoading = true,
buttonLoading = false,
onGetCardClick = {},
onBackClick = {},
),
TangemPayOnboardingScreenState(
fullScreenLoading = false,
buttonLoading = false,
onGetCardClick = {},
onBackClick = {},
),
TangemPayOnboardingScreenState(
fullScreenLoading = false,
buttonLoading = true,
onGetCardClick = {},
onBackClick = {},
),
),
)

View file

@ -4,6 +4,8 @@ import javax.annotation.concurrent.Immutable
@Immutable
internal data class TangemPayOnboardingScreenState(
val fullScreenLoading: Boolean = true,
val buttonLoading: Boolean = false,
val fullScreenLoading: Boolean,
val buttonLoading: Boolean,
val onGetCardClick: () -> Unit,
val onBackClick: () -> Unit,
)

View file

@ -18,6 +18,7 @@ import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.nft.ObserveAndClearNFTCacheIfNeedUseCase
import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.TangemPayIssueOrderUseCase
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.settings.*
@ -93,6 +94,7 @@ internal class WalletModel @Inject constructor(
private val tangemPayIssueOrderUseCase: TangemPayIssueOrderUseCase,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val yieldSupplyApyUpdateUseCase: YieldSupplyApyUpdateUseCase,
private val tangemPayOnboardingRepository: OnboardingRepository,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
val screenLifecycleProvider: ScreenLifecycleProvider,
val innerWalletRouter: InnerWalletRouter,
@ -344,16 +346,24 @@ internal class WalletModel @Inject constructor(
* and every minute while user stays on the main screen
*/
screenLifecycleProvider.isBackgroundState.onEach { inBackground ->
// fast exit
if (!tangemPayFeatureToggles.isTangemPayEnabled) return@onEach
updateTangemPayJobHolder.cancel()
if (!inBackground && tangemPayFeatureToggles.isTangemPayEnabled) {
modelScope.launch {
modelScope.launch {
// fast exit
val initialDataProduced = tangemPayOnboardingRepository.isTangemPayInitialDataProduced()
if (!initialDataProduced) return@launch
if (!inBackground) {
refreshTangemPayInfo()
while (isActive) {
delay(TANGEM_PAY_UPDATE_INTERVAL)
refreshTangemPayInfo()
}
}.saveIn(updateTangemPayJobHolder)
}
}
}.saveIn(updateTangemPayJobHolder)
}.launchIn(modelScope)
}

View file

@ -16,7 +16,10 @@ import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VisaActivationInput
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
import com.tangem.operations.derivation.DerivationTaskResponse
import com.tangem.operations.preflightread.PreflightReadFilter
import com.tangem.operations.wallet.CreateWalletResponse
@ -156,5 +159,6 @@ interface TangemSdkManager {
visaDataForApprove: VisaDataForApprove,
): CompletionResult<VisaSignedDataByCustomerWallet>
suspend fun tangemPayProduceInitialCredentials(cardId: String): CompletionResult<TangemPayInitialCredentials>
// endregion
}