Updated on 2026-08-14

This commit is contained in:
Tangem 2025-01-27 12:23:42 +03:00
parent 11c0834815
commit d27c57cd84
20 changed files with 279 additions and 93 deletions

View file

@ -48,5 +48,9 @@ internal class DefaultVisaAuthTokenStorage @Inject constructor(
?.let(tokensAdapter::fromJson)
}
override fun remove(cardId: String) {
secureStorage.delete(createKey(cardId))
}
private fun createKey(cardId: String): String = "visa_auth_tokens_$cardId"
}

View file

@ -1,7 +1,6 @@
package com.tangem.tap.domain.tasks.visa
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.CompletionCallback
@ -9,8 +8,10 @@ import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.common.map
import com.tangem.crypto.CryptoUtils
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.datasource.local.visa.VisaOTPStorage
import com.tangem.datasource.local.visa.hasSavedOTP
import com.tangem.domain.common.visa.VisaUtilities
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
@ -25,6 +26,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.*
import timber.log.Timber
import kotlin.coroutines.resume
import kotlin.jvm.Throws
@ -42,7 +44,7 @@ class VisaCardActivationTask @AssistedInject constructor(
private class SessionContext(
val visaActivationRepository: VisaActivationRepository,
val card: Card,
val cardId: String,
val session: CardSession,
)
@ -63,7 +65,7 @@ class VisaCardActivationTask @AssistedInject constructor(
val context = SessionContext(
visaActivationRepository = visaActivationRepository,
card = card,
cardId = card.cardId,
session = session,
)
@ -82,7 +84,7 @@ class VisaCardActivationTask @AssistedInject constructor(
private suspend fun SessionContext.signAuthorizationChallenge(
challengeToSign: VisaAuthChallenge.Card,
): CompletionResult<VisaCardActivationResponse> {
val attestationCommand = AttestCardKeyCommand(challenge = challengeToSign.challenge.hexToBytes())
val attestationCommand = AttestCardKeyCommand(challenge = CryptoUtils.generateRandomBytes(length = 16))
val result = suspendCancellableCoroutine { continuation ->
attestationCommand.run(session = session) { attestationResponse ->
continuation.resume(attestationResponse)
@ -91,6 +93,7 @@ class VisaCardActivationTask @AssistedInject constructor(
return when (result) {
is CompletionResult.Success -> {
Timber.tag("ASDASD").e("AttestCardKeyCommand success")
processSignedAuthorizationChallenge(
signedChallenge = challengeToSign.toSignedChallenge(
signedChallenge = result.data.cardSignature.toHexString(),
@ -99,6 +102,7 @@ class VisaCardActivationTask @AssistedInject constructor(
)
}
is CompletionResult.Failure -> {
Timber.tag("ASDASD").e("AttestCardKeyCommand failure ${result.error}")
CompletionResult.Failure(result.error)
}
}
@ -135,12 +139,12 @@ class VisaCardActivationTask @AssistedInject constructor(
)
}
visaAuthTokenStorage.store(card.cardId, tokens)
visaAuthTokenStorage.store(cardId, tokens)
return visaActivationRepository.getActivationOrderToSign()
}
private suspend fun createWallet(session: CardSession): CompletionResult<Unit> {
private suspend fun SessionContext.createWallet(session: CardSession): CompletionResult<Unit> {
coroutineScope { ensureActive() }
val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
@ -157,22 +161,21 @@ class VisaCardActivationTask @AssistedInject constructor(
when (result) {
is CompletionResult.Success -> {
Timber.tag("ASDASD").e("CreateWalletTask success")
createOTP(session)
}
is CompletionResult.Failure -> {
Timber.tag("ASDASD").e("CreateWalletTask failure ${result.error}")
CompletionResult.Failure(result.error)
}
}
}
}
private suspend fun createOTP(session: CardSession): CompletionResult<Unit> {
private suspend fun SessionContext.createOTP(session: CardSession): CompletionResult<Unit> {
coroutineScope { ensureActive() }
val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val otp = otpStorage.getOTP(card.cardId)
return if (otp != null) {
return if (otpStorage.hasSavedOTP(cardId)) {
CompletionResult.Success(Unit)
} else {
val otpCommand = GenerateOTPCommand()
@ -184,10 +187,12 @@ class VisaCardActivationTask @AssistedInject constructor(
when (result) {
is CompletionResult.Success -> {
otpStorage.saveOTP(card.cardId, result.data.rootOTP)
Timber.tag("ASDASD").e("GenerateOTPCommand success")
otpStorage.saveOTP(cardId, result.data.rootOTP)
CompletionResult.Success(Unit)
}
is CompletionResult.Failure -> {
Timber.tag("ASDASD").e("GenerateOTPCommand failure ${result.error}")
CompletionResult.Failure(result.error)
}
}
@ -195,9 +200,18 @@ class VisaCardActivationTask @AssistedInject constructor(
}
private suspend fun SessionContext.signOrder(order: ActivationOrder): CompletionResult<VisaCardActivationResponse> {
val card =
session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val wallet =
card.wallets.firstOrNull() ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val task = SignHashCommand(order.hash.hexToBytes(), wallet.publicKey)
card.wallets.firstOrNull { it.curve == VisaUtilities.mandatoryCurve }
?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val task = SignHashCommand(
hash = order.hash.hexToBytes(),
walletPublicKey = wallet.publicKey,
derivationPath = VisaUtilities.visaDefaultDerivationPath,
)
val result = suspendCancellableCoroutine { continuation ->
task.run(session) { signResult ->
continuation.resume(signResult)
@ -206,12 +220,14 @@ class VisaCardActivationTask @AssistedInject constructor(
return when (result) {
is CompletionResult.Success -> {
Timber.tag("ASDASD").e("SignHashCommand success")
handleSignedOrder(
activationOrder = order,
response = result.data,
)
}
is CompletionResult.Failure -> {
Timber.tag("ASDASD").e("SignHashCommand failure ${result.error}")
CompletionResult.Failure(result.error)
}
}
@ -226,7 +242,7 @@ class VisaCardActivationTask @AssistedInject constructor(
signature = response.signature.toHexString(),
)
val otp = otpStorage.getOTP(card.cardId) ?: return CompletionResult.Failure(
val otp = otpStorage.getOTP(cardId) ?: return CompletionResult.Failure(
TangemSdkError.Underlying(VisaActivationError.MissingRootOTP.message),
)
@ -239,10 +255,14 @@ class VisaCardActivationTask @AssistedInject constructor(
}
private suspend fun SessionContext.setupAccessCode(session: CardSession): CompletionResult<Unit> {
val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
if (card.isAccessCodeSet) {
return CompletionResult.Success(Unit)
}
Timber.tag("ASDASD").e("Setting access code: $accessCode")
val task = SetUserCodeCommand.changeAccessCode(accessCode)
val result = suspendCancellableCoroutine { continuation ->
task.run(session) { setAccessCodeResult ->
@ -252,9 +272,11 @@ class VisaCardActivationTask @AssistedInject constructor(
return when (result) {
is CompletionResult.Success -> {
Timber.tag("ASDASD").e("SetUserCodeCommand success")
CompletionResult.Success(Unit)
}
is CompletionResult.Failure -> {
Timber.tag("ASDASD").e("SetUserCodeCommand failure ${result.error}")
CompletionResult.Failure(result.error)
}
}

View file

@ -31,7 +31,7 @@ internal class VisaCardScanHandler @Inject constructor(
private class SessionContext(
val visaActivationRepository: VisaActivationRepository,
val card: Card,
val cardId: String,
val session: CardSession,
)
@ -47,7 +47,7 @@ internal class VisaCardScanHandler @Inject constructor(
val context = SessionContext(
visaActivationRepository = visaActivationRepository,
card = card,
cardId = card.cardId,
session = session,
)
@ -103,6 +103,8 @@ internal class VisaCardScanHandler @Inject constructor(
)
}
val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val wallet = card.wallets.firstOrNull { it.curve == VisaUtilities.mandatoryCurve } ?: run {
Timber.e("Failed to find extended public key while handling wallet authorization")
return CompletionResult.Failure(
@ -118,7 +120,6 @@ internal class VisaCardScanHandler @Inject constructor(
}
Timber.i("Requesting challenge for wallet authorization")
// Will be changed later after backend implementation
val challengeResponse = runCatching {
visaAuthRepository.getCustomerWalletAuthChallenge(
cardId = card.cardId,
@ -152,8 +153,6 @@ internal class VisaCardScanHandler @Inject constructor(
private suspend fun SessionContext.handleWalletAuthorizationTokens(
signedChallenge: VisaAuthSignedChallenge,
): CompletionResult<VisaCardActivationStatus> {
val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val authorizationTokensResponse = runCatching {
visaAuthRepository.getAccessTokens(signedChallenge = signedChallenge)
}.getOrElse {
@ -164,7 +163,7 @@ internal class VisaCardScanHandler @Inject constructor(
}
visaAuthTokenStorage.store(
cardId = card.cardId,
cardId = cardId,
tokens = authorizationTokensResponse,
)
@ -266,14 +265,7 @@ internal class VisaCardScanHandler @Inject constructor(
}
}
return when (result) {
is CompletionResult.Success -> {
CompletionResult.Success(result.data)
}
is CompletionResult.Failure -> {
CompletionResult.Failure(result.error)
}
}
return result
}
private suspend fun SessionContext.signChallengeWithCard(

View file

@ -1,8 +1,10 @@
package com.tangem.datasource.api.visa
import com.tangem.datasource.api.utils.ReadTimeout
import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse
import retrofit2.http.GET
import retrofit2.http.Header
import java.util.concurrent.TimeUnit
interface TangemVisaApi {
@ -10,4 +12,10 @@ interface TangemVisaApi {
suspend fun getRemoteActivationStatus(
@Header("Authorization") authHeader: String,
): CardActivationRemoteStateResponse
@ReadTimeout(duration = 20, TimeUnit.MINUTES)
@GET("activation-status")
suspend fun getRemoteActivationStatusLongPoll(
@Header("Authorization") authHeader: String,
): CardActivationRemoteStateResponse
}

View file

@ -187,7 +187,7 @@ internal object NetworkModule {
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
)
).applyTimeoutAnnotations()
},
)
}
@ -208,7 +208,7 @@ internal object NetworkModule {
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
)
).applyTimeoutAnnotations()
},
)
}

View file

@ -7,4 +7,6 @@ interface VisaAuthTokenStorage {
suspend fun store(cardId: String, tokens: VisaAuthTokens)
suspend fun get(cardId: String): VisaAuthTokens?
fun remove(cardId: String)
}

View file

@ -44,17 +44,13 @@ internal fun OkHttpClient.Builder.applyTimeoutAnnotations(): OkHttpClient.Builde
val readTimeout = tag?.method()?.getAnnotation(ReadTimeout::class.java)
val writeTimeout = tag?.method()?.getAnnotation(WriteTimeout::class.java)
chain
.apply {
connectionTimeout?.let { withConnectTimeout(timeout = it.duration, unit = it.unit) }
}
.apply {
readTimeout?.let { withReadTimeout(timeout = it.duration, unit = it.unit) }
}
.apply {
writeTimeout?.let { withWriteTimeout(timeout = it.duration, unit = it.unit) }
}
.proceed(request)
chain.run {
connectionTimeout?.let { withConnectTimeout(timeout = it.duration, unit = it.unit) } ?: this
}.run {
readTimeout?.let { withReadTimeout(timeout = it.duration, unit = it.unit) } ?: this
}.run {
writeTimeout?.let { withWriteTimeout(timeout = it.duration, unit = it.unit) } ?: this
}.proceed(request)
},
)
}

View file

@ -1,6 +1,9 @@
package com.tangem.data.visa
import com.tangem.common.extensions.toHexString
import com.tangem.crypto.CryptoUtils
import com.tangem.data.visa.converter.VisaActivationStatusConverter
import com.tangem.datasource.api.common.visa.TangemVisaAuthProvider
import com.tangem.datasource.api.visa.TangemVisaApi
import com.tangem.domain.visa.model.ActivationOrder
import com.tangem.domain.visa.model.VisaActivationRemoteState
@ -16,16 +19,26 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
private val visaApi: TangemVisaApi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val visaActivationStatusConverter: VisaActivationStatusConverter,
private val visaAuthProvider: TangemVisaAuthProvider,
) : VisaActivationRepository {
override suspend fun getActivationRemoteState(): VisaActivationRemoteState = withContext(dispatcherProvider.io) {
visaActivationStatusConverter.convert(visaApi.getRemoteActivationStatus(cardId))
// visaActivationStatusConverter.convert(visaApi.getRemoteActivationStatus(visaAuthProvider.getAuthHeader(cardId)))
VisaActivationRemoteState.CardWalletSignatureRequired // mock
// TODO implement refreshing access token if it's expired
}
override suspend fun getActivationOrderToSign(): ActivationOrder {
return ActivationOrder("TODO implement")
override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState =
withContext(dispatcherProvider.io) {
// visaActivationStatusConverter.convert(
// visaApi.getRemoteActivationStatusLongPoll(visaAuthProvider.getAuthHeader(cardId)),
// )
VisaActivationRemoteState.WaitingPinCode
}
override suspend fun getActivationOrderToSign(): ActivationOrder = withContext(dispatcherProvider.io) {
ActivationOrder(CryptoUtils.generateRandomBytes(32).toHexString())
}
@AssistedFactory

View file

@ -1,5 +1,7 @@
package com.tangem.data.visa
import com.tangem.common.extensions.toHexString
import com.tangem.crypto.CryptoUtils
import com.tangem.datasource.api.visa.TangemVisaAuthApi
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaAuthSession
@ -17,14 +19,19 @@ internal class DefaultVisaAuthRepository @Inject constructor(
override suspend fun getCardAuthChallenge(cardId: String, cardPublicKey: String): VisaAuthChallenge.Card =
withContext(dispatchers.io) {
val response = visaAuthApi.generateNonceByCard(
cardId = cardId,
cardPublicKey = cardPublicKey,
)
// val response = visaAuthApi.generateNonceByCard(
// cardId = cardId,
// cardPublicKey = cardPublicKey,
// )
//
// VisaAuthChallenge.Card(
// challenge = response.nonce,
// session = VisaAuthSession(response.sessionId),
// )
VisaAuthChallenge.Card(
challenge = response.nonce,
session = VisaAuthSession(response.sessionId),
challenge = CryptoUtils.generateRandomBytes(16).toHexString(),
session = VisaAuthSession("session"),
)
}
@ -32,39 +39,47 @@ internal class DefaultVisaAuthRepository @Inject constructor(
cardId: String,
walletPublicKey: String,
): VisaAuthChallenge.Wallet = withContext(dispatchers.io) {
val response = visaAuthApi.generateNonceByWalletAddress(
customerId = cardId,
customerWalletAddress = walletPublicKey,
)
// val response = visaAuthApi.generateNonceByWalletAddress(
// customerId = cardId,
// customerWalletAddress = walletPublicKey,
// )
//
// VisaAuthChallenge.Wallet(
// challenge = response.nonce,
// session = VisaAuthSession(response.sessionId),
// )
VisaAuthChallenge.Wallet(
challenge = response.nonce,
session = VisaAuthSession(response.sessionId),
challenge = CryptoUtils.generateRandomBytes(32).toHexString(),
session = VisaAuthSession("session"),
)
}
override suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): VisaAuthTokens =
withContext(dispatchers.io) {
val response = when (signedChallenge) {
is VisaAuthSignedChallenge.ByCardPublicKey -> {
visaAuthApi.getAccessToken(
sessionId = signedChallenge.challenge.session.sessionId,
signature = signedChallenge.signature,
salt = signedChallenge.salt,
)
}
is VisaAuthSignedChallenge.ByWallet -> {
visaAuthApi.getAccessToken(
sessionId = signedChallenge.challenge.session.sessionId,
signature = signedChallenge.signature,
salt = null,
)
}
}
// val response = when (signedChallenge) {
// is VisaAuthSignedChallenge.ByCardPublicKey -> {
// visaAuthApi.getAccessToken(
// sessionId = signedChallenge.challenge.session.sessionId,
// signature = signedChallenge.signature,
// salt = signedChallenge.salt,
// )
// }
// is VisaAuthSignedChallenge.ByWallet -> {
// visaAuthApi.getAccessToken(
// sessionId = signedChallenge.challenge.session.sessionId,
// signature = signedChallenge.signature,
// salt = null,
// )
// }
// }
//
// VisaAuthTokens(
// accessToken = response.accessToken,
// refreshToken = response.refreshToken,
// )
VisaAuthTokens(
accessToken = response.accessToken,
refreshToken = response.refreshToken,
accessToken = "accessToken",
refreshToken = "refreshToken",
)
}
}

View file

@ -7,6 +7,8 @@ interface VisaActivationRepository {
suspend fun getActivationRemoteState(): VisaActivationRemoteState
suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState
suspend fun getActivationOrderToSign(): ActivationOrder
interface Factory {

View file

@ -151,6 +151,9 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor(
)
OnboardingVisaRoute.InProgress -> OnboardingVisaInProgressComponent(
appComponentContext = factoryContext,
config = OnboardingVisaInProgressComponent.Config(
scanResponse = params.scanResponse,
),
params = OnboardingVisaInProgressComponent.Params(
childParams = childParams,
onDone = { stackNavigation.push(OnboardingVisaRoute.PinCode) },
@ -159,15 +162,19 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor(
is OnboardingVisaRoute.OtherWalletApproveOption -> OnboardingVisaOtherWalletComponent(
appComponentContext = factoryContext,
config = OnboardingVisaOtherWalletComponent.Config(
scanResponse = params.scanResponse,
visaDataForApprove = route.visaDataForApprove,
),
params = OnboardingVisaOtherWalletComponent.Params(
childParams = childParams,
onDone = { stackNavigation.push(OnboardingVisaRoute.InProgress) },
onDone = { stackNavigation.push(OnboardingVisaRoute.PinCode) },
),
)
OnboardingVisaRoute.PinCode -> OnboardingVisaPinCodeComponent(
appComponentContext = factoryContext,
config = OnboardingVisaPinCodeComponent.Config(
scanResponse = params.scanResponse,
),
params = OnboardingVisaPinCodeComponent.Params(
childParams = childParams,
onDone = { params.onDone() },

View file

@ -17,6 +17,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.math.tan
@Stable
@ComponentScoped
@ -39,6 +40,8 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
val onDone = MutableSharedFlow<OnboardingVisaAccessCodeComponent.DoneEvent>()
fun onBack() {
if (uiState.value.buttonLoading) return
when (uiState.value.step) {
OnboardingVisaAccessCodeUM.Step.Enter -> modelScope.launch { onBack.emit(Unit) }
OnboardingVisaAccessCodeUM.Step.ReEnter ->
@ -141,7 +144,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor(
OnboardingVisaAccessCodeComponent.DoneEvent(
visaDataForApprove = VisaDataForApprove(
targetAddress = "x9F65354e595284956599F2892fA4A4a87653D6E6",
approveHash = "approve hash",
approveHash = "48b55c482123a10ad9022f9f4c5dd95c",
),
walletFound = false, // TODO
),

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.features.onboarding.v2.visa.impl.DefaultOnboardingVisaComponent
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.model.OnboardingVisaInProgressModel
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.ui.OnboardingVisaInProgress
@ -15,10 +16,11 @@ import kotlinx.coroutines.flow.onEach
internal class OnboardingVisaInProgressComponent(
appComponentContext: AppComponentContext,
config: Config,
private val params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: OnboardingVisaInProgressModel = getOrCreateModel()
private val model: OnboardingVisaInProgressModel = getOrCreateModel(config)
init {
model.onDone
@ -35,6 +37,10 @@ internal class OnboardingVisaInProgressComponent(
)
}
data class Config(
val scanResponse: ScanResponse,
)
data class Params(
val childParams: DefaultOnboardingVisaComponent.ChildParams,
val onDone: () -> Unit,

View file

@ -3,6 +3,10 @@ package com.tangem.features.onboarding.v2.visa.impl.child.inprogress.model
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
@ -12,16 +16,29 @@ import javax.inject.Inject
@Stable
@ComponentScoped
internal class OnboardingVisaInProgressModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val visaActivationRepositoryFactory: VisaActivationRepository.Factory,
) : Model() {
private val params = paramsContainer.require<OnboardingVisaInProgressComponent.Config>()
private val visaActivationRepository = visaActivationRepositoryFactory.create(params.scanResponse.card.cardId)
val onDone = MutableSharedFlow<Unit>()
init {
modelScope.launch {
// TODO check state
delay(timeMillis = 2000)
onDone.emit(Unit)
while (true) {
val result = runCatching {
visaActivationRepository.getActivationRemoteStateLongPoll()
}.getOrNull()
if (result == VisaActivationRemoteState.WaitingPinCode) {
onDone.emit(Unit)
break
}
delay(timeMillis = 1000)
}
}
}
}

View file

@ -9,6 +9,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.features.onboarding.v2.visa.impl.DefaultOnboardingVisaComponent
import com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.model.OnboardingVisaOtherWalletModel
@ -43,6 +44,7 @@ internal class OnboardingVisaOtherWalletComponent(
}
data class Config(
val scanResponse: ScanResponse,
val visaDataForApprove: VisaDataForApprove,
)

View file

@ -4,12 +4,18 @@ import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.OnboardingVisaOtherWalletComponent
import com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.ui.state.OnboardingVisaOtherWalletUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ -17,15 +23,36 @@ import javax.inject.Inject
internal class OnboardingVisaOtherWalletModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
private val visaActivationRepositoryFactory: VisaActivationRepository.Factory,
) : Model() {
@Suppress("UnusedPrivateMember")
private val config = paramsContainer.require<OnboardingVisaOtherWalletComponent.Config>()
private val visaActivationRepository = visaActivationRepositoryFactory.create(config.scanResponse.card.cardId)
private val _uiState = MutableStateFlow(getInitialState())
val uiState = _uiState.asStateFlow()
val onDone = MutableSharedFlow<Unit>()
init {
modelScope.launch {
while (true) {
val result = runCatching {
visaActivationRepository.getActivationRemoteStateLongPoll()
}.getOrNull()
if (result == VisaActivationRemoteState.WaitingPinCode) {
onDone.emit(Unit)
break
}
delay(timeMillis = 1000)
}
}
}
private fun getInitialState(): OnboardingVisaOtherWalletUM {
return OnboardingVisaOtherWalletUM(
onShareClick = ::onShareClicked,
@ -34,10 +61,10 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor(
}
private fun onShareClicked() {
// TODO
shareManager.shareText("https://tangem.com/${config.visaDataForApprove.approveHash}")
}
private fun onOpenInBrowserClicked() {
// TODO
urlOpener.openUrl("https://tangem.com/${config.visaDataForApprove.approveHash}")
}
}

View file

@ -10,6 +10,7 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.security.DisableScreenshotsDisposableEffect
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.features.onboarding.v2.visa.impl.DefaultOnboardingVisaComponent
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.model.OnboardingVisaPinCodeModel
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.OnboardingVisaPinCode
@ -17,10 +18,11 @@ import kotlinx.coroutines.launch
internal class OnboardingVisaPinCodeComponent(
appComponentContext: AppComponentContext,
config: Config,
private val params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: OnboardingVisaPinCodeModel = getOrCreateModel()
private val model: OnboardingVisaPinCodeModel = getOrCreateModel(config)
init {
componentScope.launch {
@ -42,6 +44,10 @@ internal class OnboardingVisaPinCodeComponent(
)
}
data class Config(
val scanResponse: ScanResponse,
)
data class Params(
val childParams: DefaultOnboardingVisaComponent.ChildParams,
val onDone: () -> Unit,

View file

@ -3,6 +3,15 @@ package com.tangem.features.onboarding.v2.visa.impl.child.pincode.model
import androidx.compose.runtime.Stable
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.model.VisaCardActivationStatus
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.OnboardingVisaPinCodeComponent
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state.OnboardingVisaPinCodeUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableSharedFlow
@ -10,14 +19,21 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@Stable
@ComponentScoped
internal class OnboardingVisaPinCodeModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletsListManager: UserWalletsListManager,
private val authTokenStorage: VisaAuthTokenStorage,
private val otpStorage: VisaAuthTokenStorage,
) : Model() {
private val params = paramsContainer.require<OnboardingVisaPinCodeComponent.Config>()
private val _uiState = MutableStateFlow(getInitialState())
val uiState = _uiState.asStateFlow()
@ -31,21 +47,61 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
}
private fun onPinCodeChange(pin: String) {
if (pin.all { it.isDigit() }) _uiState.update { it.copy(pinCode = pin) }
if (pin.all { it.isDigit() }) {
_uiState.update {
it.copy(
pinCode = pin,
submitButtonEnabled = checkPinCode(pin),
)
}
}
}
private fun onSubmitClick() {
if (checkPinCode(_uiState.value.pinCode).not()) return
// TODO
modelScope.launch {
loading(true)
modelScope.launch { onDone.emit(Unit) }
// TODO
// make backend call
saveWallet()
loading(false)
}
}
private fun checkPinCode(pin: String): Boolean {
return pin.length == PIN_CODE_LENGTH
}
private fun loading(state: Boolean) {
_uiState.update { it.copy(submitButtonLoading = state) }
}
private suspend fun saveWallet() {
val userWallet = createUserWallet(params.scanResponse)
userWalletsListManager.save(userWallet)
authTokenStorage.remove(params.scanResponse.card.cardId)
otpStorage.remove(params.scanResponse.card.cardId)
onDone.emit(Unit)
}
private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet = withContext(dispatchers.io) {
val newActivationStatus = VisaCardActivationStatus.Activated(
visaAuthTokens = authTokenStorage.get(scanResponse.card.cardId)
?: error("Impossible state. Wrong feature implementation"),
)
requireNotNull(
value = UserWalletBuilder(
scanResponse.copy(visaCardActivationStatus = newActivationStatus),
generateWalletNameUseCase,
).build(),
lazyMessage = { "User wallet not created" },
)
}
private companion object {
const val PIN_CODE_LENGTH = 4
}

View file

@ -28,9 +28,11 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state.OnboardingVisaPinCodeUM
@ -72,13 +74,17 @@ internal fun OnboardingVisaPinCode(state: OnboardingVisaPinCodeUM, modifier: Mod
PinCodeSection(state)
}
PrimaryButton(
NavigationPrimaryButton(
modifier = Modifier
.imePadding()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
text = "Submit",
onClick = state.onSubmitClick,
primaryButton = NavigationButton(
textReference = TextReference.Str("Submit"),
onClick = state.onSubmitClick,
showProgress = state.submitButtonLoading,
isEnabled = state.submitButtonEnabled,
),
)
}
}

View file

@ -3,5 +3,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state
internal data class OnboardingVisaPinCodeUM(
val pinCode: String = "",
val onPinCodeChange: (String) -> Unit = {},
val submitButtonLoading: Boolean = false,
val submitButtonEnabled: Boolean = true,
val onSubmitClick: () -> Unit = {},
)