Updated on 2026-08-14

This commit is contained in:
Tangem 2025-02-11 13:35:31 +03:00
parent 19fb8f2bcd
commit ecdb72e55c
58 changed files with 1082 additions and 346 deletions

View file

@ -1,49 +1,195 @@
package com.tangem.data.visa
import com.tangem.common.extensions.toHexString
import com.tangem.crypto.CryptoUtils
import com.tangem.data.visa.converter.AccessCodeDataConverter
import com.tangem.data.visa.converter.VisaActivationStatusConverter
import com.tangem.datasource.api.common.visa.TangemVisaAuthProvider
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.visa.TangemVisaApi
import com.tangem.domain.visa.model.ActivationOrder
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.datasource.api.visa.models.request.ActivationByCardWalletRequest
import com.tangem.datasource.api.visa.models.request.ActivationByCustomerWalletRequest
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.visa.exception.RefreshTokenExpiredException
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.withContext
@Suppress("UnusedPrivateMember")
@Suppress("LongParameterList")
internal class DefaultVisaActivationRepository @AssistedInject constructor(
@Assisted private val cardId: String,
@Assisted private val visaCardId: VisaCardId,
private val visaApi: TangemVisaApi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val visaActivationStatusConverter: VisaActivationStatusConverter,
private val visaAuthProvider: TangemVisaAuthProvider,
private val visaAuthTokenStorage: VisaAuthTokenStorage,
private val accessCodeDataConverter: AccessCodeDataConverter,
private val visaAuthRepository: VisaAuthRepository,
) : VisaActivationRepository {
override suspend fun getActivationRemoteState(): VisaActivationRemoteState = withContext(dispatcherProvider.io) {
// visaActivationStatusConverter.convert(visaApi.getRemoteActivationStatus(visaAuthProvider.getAuthHeader(cardId)))
VisaActivationRemoteState.CardWalletSignatureRequired // mock
// TODO implement refreshing access token if it's expired
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getRemoteActivationStatus(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
).getOrThrow()
}
visaActivationStatusConverter.convert(result)
}
override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState =
withContext(dispatcherProvider.io) {
// visaActivationStatusConverter.convert(
// visaApi.getRemoteActivationStatusLongPoll(visaAuthProvider.getAuthHeader(cardId)),
// )
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
VisaActivationRemoteState.WaitingPinCode
visaApi.getRemoteActivationStatusLongPoll(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
cardId = visaCardId.cardId,
cardPublicKey = visaCardId.cardPublicKey,
).getOrThrow()
}
visaActivationStatusConverter.convert(result)
}
override suspend fun getActivationOrderToSign(): ActivationOrder = withContext(dispatcherProvider.io) {
ActivationOrder(CryptoUtils.generateRandomBytes(length = 32).toHexString())
override suspend fun getCardWalletAcceptanceData(
request: VisaCardWalletDataToSignRequest,
): VisaDataToSignByCardWallet = withContext(dispatcherProvider.io) {
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getCardWalletAcceptance(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = request.orderId,
customerWalletAddress = request.customerWalletAddress,
).getOrThrow()
}
VisaDataToSignByCardWallet(
request = request,
hashToSign = result.dataForCardWallet.hash,
)
}
override suspend fun getCustomerWalletAcceptanceData(
request: VisaCustomerWalletDataToSignRequest,
): VisaDataToSignByCustomerWallet = withContext(dispatcherProvider.io) {
val result = request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.getCustomerWalletAcceptance(
authHeader = authTokens.getAuthHeader(),
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = request.orderId,
cardWalletAddress = request.cardWalletAddress,
).getOrThrow()
}
VisaDataToSignByCustomerWallet(
request = request,
hashToSign = result.dataForCardWallet.hash,
)
}
override suspend fun activateCard(signedData: VisaSignedActivationDataByCardWallet) {
withContext(dispatcherProvider.io) {
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.activateByCardWallet(
authHeader = authTokens.getAuthHeader(),
body = ActivationByCardWalletRequest(
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = signedData.dataToSign.request.orderId,
data = ActivationByCardWalletRequest.Data(
cardWallet = ActivationByCardWalletRequest.CardWallet(
address = signedData.cardWalletAddress,
cardWalletConfirmation = null, // for second iteration
deployAcceptanceSignature = signedData.signature,
),
otp = ActivationByCardWalletRequest.Otp(
rootOtp = signedData.rootOTP,
counter = signedData.otpCounter,
),
),
),
)
}
}
}
override suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet) {
withContext(dispatcherProvider.io) {
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.activateByCustomerWallet(
authHeader = authTokens.getAuthHeader(),
body = ActivationByCustomerWalletRequest(
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = signedData.dataToSign.request.orderId,
data = ActivationByCustomerWalletRequest.Data(
customerWallet = ActivationByCustomerWalletRequest.CustomerWallet(
address = signedData.customerWalletAddress,
deployAcceptanceSignature = signedData.signature,
),
),
),
)
}
}
}
private suspend fun <T : Any> request(requestBlock: suspend () -> T): T {
return runCatching {
requestBlock()
}.getOrElse { responseError ->
if (responseError !is ApiResponseError.HttpException ||
responseError.code != ApiResponseError.HttpException.Code.UNAUTHORIZED
) {
throw responseError
}
val authTokens = visaAuthTokenStorage.get(visaCardId.cardId) ?: error("Auth tokens are not stored")
val newTokens = runCatching {
visaAuthRepository.refreshAccessTokens(authTokens.refreshToken)
}.getOrElse { throw RefreshTokenExpiredException() }
visaAuthTokenStorage.store(visaCardId.cardId, newTokens)
requestBlock()
}
}
@AssistedFactory
interface Factory : VisaActivationRepository.Factory {
override fun create(cardId: String): DefaultVisaActivationRepository
override fun create(cardId: VisaCardId): DefaultVisaActivationRepository
}
}

View file

@ -36,24 +36,22 @@ internal class DefaultVisaAuthRepository @Inject constructor(
)
}
override suspend fun getCustomerWalletAuthChallenge(
cardId: String,
walletPublicKey: String,
): VisaAuthChallenge.Wallet = withContext(dispatchers.io) {
// val response = visaAuthApi.generateNonceByWalletAddress(
// customerId = cardId,
// customerWalletAddress = walletPublicKey,
// )
//
// VisaAuthChallenge.Wallet(
// challenge = response.nonce,
// session = VisaAuthSession(response.sessionId),
// )
VisaAuthChallenge.Wallet(
challenge = CryptoUtils.generateRandomBytes(length = 32).toHexString(),
session = VisaAuthSession("session"),
)
}
override suspend fun getCardWalletAuthChallenge(cardWalletAddress: String): VisaAuthChallenge.Wallet =
withContext(dispatchers.io) {
// val response = visaAuthApi.generateNonceByWalletAddress(
// customerId = cardId,
// customerWalletAddress = walletPublicKey,
// )
//
// VisaAuthChallenge.Wallet(
// challenge = response.nonce,
// session = VisaAuthSession(response.sessionId),
// )
VisaAuthChallenge.Wallet(
challenge = CryptoUtils.generateRandomBytes(length = 32).toHexString(),
session = VisaAuthSession("session"),
)
}
override suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): VisaAuthTokens =
withContext(dispatchers.io) {

View file

@ -0,0 +1,50 @@
package com.tangem.data.visa
import com.tangem.common.extensions.toHexString
import com.tangem.crypto.CryptoUtils
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
class MockVisaActivationRepository @AssistedInject constructor(
@Assisted private val visaCardId: VisaCardId,
) : VisaActivationRepository {
override suspend fun getActivationRemoteState(): VisaActivationRemoteState {
return VisaActivationRemoteState.PaymentAccountDeploying
}
override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState {
return VisaActivationRemoteState.WaitingPinCode
}
override suspend fun getCardWalletAcceptanceData(
request: VisaCardWalletDataToSignRequest,
): VisaDataToSignByCardWallet {
return VisaDataToSignByCardWallet(
request = request,
hashToSign = CryptoUtils.generateRandomBytes(length = 32).toHexString(),
)
}
override suspend fun getCustomerWalletAcceptanceData(
request: VisaCustomerWalletDataToSignRequest,
): VisaDataToSignByCustomerWallet {
return VisaDataToSignByCustomerWallet(
request = request,
CryptoUtils.generateRandomBytes(length = 32).toHexString(),
)
}
override suspend fun activateCard(signedData: VisaSignedActivationDataByCardWallet) {}
override suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet) {}
@AssistedFactory
interface Factory : VisaActivationRepository.Factory {
override fun create(cardId: VisaCardId): MockVisaActivationRepository
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.data.visa.converter
import com.squareup.moshi.Moshi
import com.tangem.data.visa.model.AccessCodeData
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.utils.converter.Converter
import okio.ByteString.Companion.decodeBase64
import javax.inject.Inject
import javax.inject.Singleton
private const val JWT_PAYLOAD_INDEX = 1
@Singleton
internal class AccessCodeDataConverter @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
) : Converter<VisaAuthTokens, AccessCodeData> {
private val adapter = moshi.adapter(AccessCodeData::class.java)
override fun convert(value: VisaAuthTokens): AccessCodeData {
val payloadBase64 = value.accessToken.split(".").getOrNull(JWT_PAYLOAD_INDEX)
val decodedString = payloadBase64?.decodeBase64()?.utf8()
val data = decodedString?.let { adapter.fromJson(it) }
requireNotNull(data) { "Invalid access token" }
return data
}
}

View file

@ -1,8 +1,8 @@
package com.tangem.data.visa.di
import com.tangem.data.visa.DefaultVisaActivationRepository
import com.tangem.data.visa.DefaultVisaAuthRepository
import com.tangem.data.visa.DummyVisaRepository
import com.tangem.data.visa.MockVisaActivationRepository
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.visa.repository.VisaRepository
@ -36,9 +36,16 @@ internal interface VisaDataBindsModule {
@Singleton
fun bindVisaAuthRepository(repository: DefaultVisaAuthRepository): VisaAuthRepository
// @Binds
// @Singleton
// fun bindVisaActivationRepositoryFactory(
// repository: DefaultVisaActivationRepository.Factory,
// ): VisaActivationRepository.Factory
// Mocked
@Binds
@Singleton
fun bindVisaActivationRepositoryFactory(
repository: DefaultVisaActivationRepository.Factory,
repository: MockVisaActivationRepository.Factory,
): VisaActivationRepository.Factory
}

View file

@ -0,0 +1,10 @@
package com.tangem.data.visa.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class AccessCodeData(
@Json(name = "pid") val productInstanceId: String,
@Json(name = "sub") val customerId: String,
)