Updated on 2026-08-14

This commit is contained in:
Tangem 2025-02-11 14:22:10 +03:00
parent ecdb72e55c
commit 46a28ab22c
15 changed files with 188 additions and 15 deletions

View file

@ -3,32 +3,37 @@ package com.tangem.tap.di.domain
import com.tangem.domain.visa.GetVisaCurrencyUseCase
import com.tangem.domain.visa.GetVisaTxDetailsUseCase
import com.tangem.domain.visa.GetVisaTxHistoryUseCase
import com.tangem.domain.visa.SetVisaPinCodeUseCase
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.repository.VisaRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(ViewModelComponent::class)
@InstallIn(SingletonComponent::class)
internal object VisaDomainModule {
@Provides
@ViewModelScoped
fun provideVisaCurrencyUseCase(visaRepository: VisaRepository): GetVisaCurrencyUseCase {
return GetVisaCurrencyUseCase(visaRepository)
}
@Provides
@ViewModelScoped
fun provideGetVisaTxHistoryUseCase(visaRepository: VisaRepository): GetVisaTxHistoryUseCase {
return GetVisaTxHistoryUseCase(visaRepository)
}
@Provides
@ViewModelScoped
fun provideGetVisaTxDetailsUseCase(visaRepository: VisaRepository): GetVisaTxDetailsUseCase {
return GetVisaTxDetailsUseCase(visaRepository)
}
@Provides
fun provideSetVisaPinCodeUseCase(
visaActivationRepositoryFactory: VisaActivationRepository.Factory,
): SetVisaPinCodeUseCase {
return SetVisaPinCodeUseCase(visaActivationRepositoryFactory)
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.utils.ReadTimeout
import com.tangem.datasource.api.visa.models.request.ActivationByCardWalletRequest
import com.tangem.datasource.api.visa.models.request.ActivationByCustomerWalletRequest
import com.tangem.datasource.api.visa.models.request.SetPinCodeRequest
import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse
import com.tangem.datasource.api.visa.models.response.CardWalletDataToSignResponse
import com.tangem.datasource.api.visa.models.response.CustomerWalletDataToSignResponse
@ -64,4 +65,10 @@ interface TangemVisaApi {
@Header("Authorization") authHeader: String,
@Body body: ActivationByCustomerWalletRequest,
): ApiResponse<Unit>
@POST("product_instance/issuer_activation")
suspend fun setPinCode(
@Header("Authorization") authHeader: String,
@Body body: SetPinCodeRequest,
): ApiResponse<Unit>
}

View file

@ -0,0 +1,18 @@
package com.tangem.datasource.api.visa.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SetPinCodeRequest(
@Json(name = "customer_id") val customerId: String,
@Json(name = "activation_order_id") val activationOrderId: String,
@Json(name = "product_instance_id") val productInstanceId: String,
@Json(name = "data") val data: Data,
) {
data class Data(
@Json(name = "session_key") val sessionKey: String,
@Json(name = "iv") val iv: String,
@Json(name = "encrypted_pin") val encryptedPin: String,
)
}

View file

@ -7,6 +7,7 @@ import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.visa.TangemVisaApi
import com.tangem.datasource.api.visa.models.request.ActivationByCardWalletRequest
import com.tangem.datasource.api.visa.models.request.ActivationByCustomerWalletRequest
import com.tangem.datasource.api.visa.models.request.SetPinCodeRequest
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.visa.exception.RefreshTokenExpiredException
import com.tangem.domain.visa.model.*
@ -167,6 +168,30 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
}
}
override suspend fun sendPinCode(pinCode: VisaEncryptedPinCode) {
withContext(dispatcherProvider.io) {
request {
val authTokens =
checkNotNull(visaAuthTokenStorage.get(visaCardId.cardId)) { "Visa auth tokens are not stored" }
val accessCodeData = accessCodeDataConverter.convert(authTokens)
visaApi.setPinCode(
authHeader = authTokens.getAuthHeader(),
body = SetPinCodeRequest(
customerId = accessCodeData.customerId,
productInstanceId = accessCodeData.productInstanceId,
activationOrderId = pinCode.activationOrderId,
data = SetPinCodeRequest.Data(
sessionKey = pinCode.sessionId,
iv = pinCode.iv,
encryptedPin = pinCode.encryptedPin,
),
),
)
}
}
}
private suspend fun <T : Any> request(requestBlock: suspend () -> T): T {
return runCatching {
requestBlock()

View file

@ -43,6 +43,8 @@ class MockVisaActivationRepository @AssistedInject constructor(
override suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet) {}
override suspend fun sendPinCode(pinCode: VisaEncryptedPinCode) {}
@AssistedFactory
interface Factory : VisaActivationRepository.Factory {
override fun create(cardId: VisaCardId): MockVisaActivationRepository

View file

@ -0,0 +1,8 @@
package com.tangem.domain.visa.model
data class VisaEncryptedPinCode(
val activationOrderId: String,
val sessionId: String,
val iv: String,
val encryptedPin: String,
)

View file

@ -0,0 +1,75 @@
@file:Suppress("MaximumLineLength")
package com.tangem.domain.visa
import android.util.Base64
import arrow.core.Either
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.visa.model.VisaEncryptedPinCode
import com.tangem.domain.visa.repository.VisaActivationRepository
import java.security.Key
import java.security.KeyFactory
import java.security.PublicKey
import java.security.SecureRandom
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
private const val KEY_SIZE = 256
private const val RSA_PUB_KEY = "TODO" // TODO add RSA public key [REDACTED_TASK_KEY]
class SetVisaPinCodeUseCase(
private val visaActivationRepositoryFactory: VisaActivationRepository.Factory,
) {
suspend operator fun invoke(
visaCardId: VisaCardId,
activationOrderId: String,
pinCode: String,
): Either<Throwable, Unit> = Either.catch {
val visaActivationRepository = visaActivationRepositoryFactory.create(visaCardId)
val sessionKey = generateSessionKey()
val sessionId = getSessionId(RSA_PUB_KEY, sessionKey)
val secureRandom = SecureRandom()
val iv = ByteArray(size = 16)
secureRandom.nextBytes(iv)
val ivParameterSpec = IvParameterSpec(iv)
val ivPayload = Base64.encodeToString(iv, Base64.NO_WRAP)
val aesKey = SecretKeySpec(sessionKey.encoded, 0, sessionKey.encoded.size, "AES")
val aesCipher = Cipher.getInstance("AES/CBC/PKCS5PADDING")
aesCipher.init(Cipher.ENCRYPT_MODE, aesKey, ivParameterSpec)
val encryptedPin = Base64.encodeToString(aesCipher.doFinal(pinCode.toByteArray()), Base64.NO_WRAP)
visaActivationRepository.sendPinCode(
VisaEncryptedPinCode(
activationOrderId = activationOrderId,
sessionId = sessionId,
iv = ivPayload,
encryptedPin = encryptedPin,
),
)
}
private fun generateSessionKey(): Key {
val generator = KeyGenerator.getInstance("AES")
generator.init(KEY_SIZE)
return generator.generateKey()
}
private fun getPublicKey(rsaPublicKey: String): PublicKey {
val keyBytes = Base64.decode(rsaPublicKey, Base64.NO_WRAP)
val spec = X509EncodedKeySpec(keyBytes)
return KeyFactory.getInstance("RSA").generatePublic(spec)
}
private fun getSessionId(rsaPublicKey: String, sessionKey: Key): String {
val publicKey = getPublicKey(rsaPublicKey)
val cipher = Cipher.getInstance("RSA")
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
return Base64.encodeToString(cipher.doFinal(sessionKey.encoded), Base64.NO_WRAP)
}
}

View file

@ -18,6 +18,8 @@ interface VisaActivationRepository {
suspend fun approveByCustomerWallet(signedData: VisaSignedDataByCustomerWallet)
suspend fun sendPinCode(pinCode: VisaEncryptedPinCode)
interface Factory {
fun create(cardId: VisaCardId): VisaActivationRepository
}

View file

@ -126,7 +126,13 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor(
),
params = OnboardingVisaInProgressComponent.Params(
childParams = childParams,
onDone = { model.stackNavigation.push(OnboardingVisaRoute.PinCode) },
onDone = {
model.stackNavigation.push(
OnboardingVisaRoute.PinCode(
activationOrderId = "TODO", // TODO [REDACTED_TASK_KEY] [Visa] In progress screen after pin code
),
)
},
),
)
is OnboardingVisaRoute.OtherWalletApproveOption -> OnboardingVisaOtherWalletComponent(
@ -137,13 +143,20 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor(
),
params = OnboardingVisaOtherWalletComponent.Params(
childParams = childParams,
onDone = { model.stackNavigation.push(OnboardingVisaRoute.PinCode) },
onDone = {
model.stackNavigation.push(
OnboardingVisaRoute.PinCode(
activationOrderId = route.visaDataForApprove.dataToSign.request.orderId,
),
)
},
),
)
OnboardingVisaRoute.PinCode -> OnboardingVisaPinCodeComponent(
is OnboardingVisaRoute.PinCode -> OnboardingVisaPinCodeComponent(
appComponentContext = factoryContext,
config = OnboardingVisaPinCodeComponent.Config(
scanResponse = model.currentScanResponse.value,
activationOrderId = route.activationOrderId,
),
params = OnboardingVisaPinCodeComponent.Params(
childParams = childParams,

View file

@ -46,6 +46,7 @@ internal class OnboardingVisaPinCodeComponent(
data class Config(
val scanResponse: ScanResponse,
val activationOrderId: String,
)
data class Params(

View file

@ -1,12 +1,15 @@
package com.tangem.features.onboarding.v2.visa.impl.child.pincode.model
import androidx.compose.runtime.Stable
import com.tangem.common.extensions.toHexString
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.visa.SetVisaPinCodeUseCase
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
@ -24,6 +27,7 @@ import javax.inject.Inject
@Stable
@ComponentScoped
@Suppress("LongParameterList")
internal class OnboardingVisaPinCodeModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
@ -31,9 +35,14 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
private val userWalletsListManager: UserWalletsListManager,
private val authTokenStorage: VisaAuthTokenStorage,
private val otpStorage: VisaAuthTokenStorage,
private val setVisaPinCodeUseCase: SetVisaPinCodeUseCase,
) : Model() {
private val params = paramsContainer.require<OnboardingVisaPinCodeComponent.Config>()
private val visaCardId = VisaCardId(
cardId = params.scanResponse.card.cardId,
cardPublicKey = params.scanResponse.card.cardPublicKey.toHexString(),
)
private val _uiState = MutableStateFlow(getInitialState())
val uiState = _uiState.asStateFlow()
@ -41,6 +50,7 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
private fun getInitialState(): OnboardingVisaPinCodeUM {
return OnboardingVisaPinCodeUM(
submitButtonEnabled = false,
onPinCodeChange = ::onPinCodeChange,
onSubmitClick = ::onSubmitClick,
)
@ -63,8 +73,15 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
modelScope.launch {
loading(true)
// TODO
// make backend call
setVisaPinCodeUseCase(
pinCode = _uiState.value.pinCode,
visaCardId = visaCardId,
activationOrderId = params.activationOrderId,
).onLeft {
loading(false)
return@launch
}
saveWallet()
loading(false)

View file

@ -119,7 +119,7 @@ internal class OnboardingVisaModel @Inject constructor(
is OnboardingVisaRoute.Welcome,
is OnboardingVisaRoute.ChooseWallet,
OnboardingVisaRoute.InProgress,
OnboardingVisaRoute.PinCode,
is OnboardingVisaRoute.PinCode,
-> {
}
}

View file

@ -28,5 +28,5 @@ sealed class OnboardingVisaRoute {
data object InProgress : OnboardingVisaRoute()
@Serializable
data object PinCode : OnboardingVisaRoute()
data class PinCode(val activationOrderId: String) : OnboardingVisaRoute()
}

View file

@ -8,7 +8,7 @@ internal fun OnboardingVisaRoute.stepNum() = when (this) {
is OnboardingVisaRoute.OtherWalletApproveOption -> 4
is OnboardingVisaRoute.TangemWalletApproveOption -> 4
OnboardingVisaRoute.InProgress -> 5
OnboardingVisaRoute.PinCode -> 6
is OnboardingVisaRoute.PinCode -> 6
}
internal const val ONBOARDING_VISA_STEPS_COUNT = 7

View file

@ -8,7 +8,7 @@ internal fun OnboardingVisaRoute.screenTitle(): TextReference = when (this) {
is OnboardingVisaRoute.ChooseWallet -> TextReference.Str("Account activation")
OnboardingVisaRoute.InProgress -> TextReference.Str("In progress")
is OnboardingVisaRoute.OtherWalletApproveOption -> TextReference.Str("Wallet connection")
OnboardingVisaRoute.PinCode -> TextReference.Str("PIN code")
is OnboardingVisaRoute.PinCode -> TextReference.Str("PIN code")
is OnboardingVisaRoute.TangemWalletApproveOption -> TextReference.Str("Wallet connection")
is OnboardingVisaRoute.Welcome -> TextReference.Str("Getting started")
}