Updated on 2026-08-14
This commit is contained in:
parent
5579cc1372
commit
a04992591a
24 changed files with 707 additions and 29 deletions
|
|
@ -269,8 +269,8 @@ dependencies {
|
|||
debugImplementation(projects.features.kyc.impl)
|
||||
internalImplementation(projects.features.kyc.impl)
|
||||
mockedImplementation(projects.features.kyc.impl)
|
||||
releaseImplementation(projects.features.kyc.impl)
|
||||
externalImplementation(projects.features.kyc.impl)
|
||||
releaseImplementation(projects.features.kyc.mock)
|
||||
externalImplementation(projects.features.kyc.mock)
|
||||
implementation(projects.features.welcome.api)
|
||||
implementation(projects.features.welcome.impl)
|
||||
implementation(projects.features.createWalletSelection.api)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
},
|
||||
{
|
||||
"name": "TANGEM_PAY_ENABLED",
|
||||
"version": "5.30.0"
|
||||
"version": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "NEW_TOKEN_RECEIVE_ENABLED",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import retrofit2.http.Body
|
|||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
|
|
@ -146,4 +147,10 @@ interface TangemPayApi {
|
|||
@Header("Authorization") authHeader: String,
|
||||
@Body body: CardDetailsRequest,
|
||||
): ApiResponse<CardDetailsResponse>
|
||||
|
||||
@PUT("v1/customer/card/pin")
|
||||
suspend fun setPin(
|
||||
@Header("Authorization") authHeader: String,
|
||||
@Body body: SetPinRequest,
|
||||
): ApiResponse<SetPinResponse>
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.tangem.datasource.api.pay.models.request
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SetPinRequest(
|
||||
@Json(name = "pin") val pin: String,
|
||||
@Json(name = "session_id") val sessionId: String,
|
||||
@Json(name = "iv") val iv: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.datasource.api.pay.models.request
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SetPinResponse(
|
||||
@Json(name = "result") val result: Result?,
|
||||
@Json(name = "error") val error: String?,
|
||||
) {
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Result(
|
||||
@Json(name = "result") val result: String,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.data.pay.entity
|
||||
|
||||
internal data class EncryptedData(
|
||||
val encryptedBase64: String,
|
||||
val ivBase64: String,
|
||||
)
|
||||
|
|
@ -9,6 +9,8 @@ import com.tangem.datasource.api.common.config.ApiEnvironment
|
|||
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
|
||||
import com.tangem.datasource.api.pay.TangemPayApi
|
||||
import com.tangem.datasource.api.pay.models.request.CardDetailsRequest
|
||||
import com.tangem.datasource.api.pay.models.request.SetPinRequest
|
||||
import com.tangem.domain.pay.model.SetPinResult
|
||||
import com.tangem.domain.pay.model.TangemPayCardBalance
|
||||
import com.tangem.domain.pay.model.TangemPayCardDetails
|
||||
import com.tangem.domain.pay.repository.CardDetailsRepository
|
||||
|
|
@ -39,7 +41,7 @@ internal class DefaultCardDetailsRepository @Inject constructor(
|
|||
override suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails> {
|
||||
return requestHelper.runWithErrorLogs(TAG) {
|
||||
val publicKeyBase64 = getPublicKeyBase64()
|
||||
val (secretKeyHex, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
|
||||
val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
|
||||
|
||||
val result = requestHelper.request { authHeader ->
|
||||
tangemPayApi.revealCardDetails(
|
||||
|
|
@ -51,14 +53,15 @@ internal class DefaultCardDetailsRepository @Inject constructor(
|
|||
val pan = rainCryptoUtil.decryptSecret(
|
||||
base64Secret = result.pan.secret,
|
||||
base64Iv = result.pan.iv,
|
||||
secretKeyHex = secretKeyHex,
|
||||
secretKeyBytes = secretKeyBytes,
|
||||
)
|
||||
|
||||
val cvv = rainCryptoUtil.decryptSecret(
|
||||
base64Secret = result.cvv.secret,
|
||||
base64Iv = result.cvv.iv,
|
||||
secretKeyHex = secretKeyHex,
|
||||
secretKeyBytes = secretKeyBytes,
|
||||
)
|
||||
secretKeyBytes.fill(0)
|
||||
|
||||
TangemPayCardDetails(
|
||||
pan = pan,
|
||||
|
|
@ -69,6 +72,32 @@ internal class DefaultCardDetailsRepository @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun setPin(pin: String): Either<UniversalError, SetPinResult> {
|
||||
return requestHelper.runWithErrorLogs(TAG) {
|
||||
val publicKeyBase64 = getPublicKeyBase64()
|
||||
val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
|
||||
val encryptedData = rainCryptoUtil.encryptPin(pin = pin, secretKeyBytes = secretKeyBytes)
|
||||
secretKeyBytes.fill(0)
|
||||
|
||||
val status = requestHelper.request { authHeader ->
|
||||
tangemPayApi.setPin(
|
||||
authHeader = authHeader,
|
||||
body = SetPinRequest(
|
||||
sessionId = sessionId,
|
||||
pin = encryptedData.encryptedBase64,
|
||||
iv = encryptedData.ivBase64,
|
||||
),
|
||||
)
|
||||
}.result?.result ?: error("Cannot set pin code")
|
||||
when (status) {
|
||||
SetPinResult.SUCCESS.name -> SetPinResult.SUCCESS
|
||||
SetPinResult.PIN_TOO_WEAK.name -> SetPinResult.PIN_TOO_WEAK
|
||||
SetPinResult.DECRYPTION_ERROR.name -> SetPinResult.DECRYPTION_ERROR
|
||||
else -> SetPinResult.UNKNOWN_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getPublicKeyBase64(): String {
|
||||
val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment
|
||||
return when (env) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
package com.tangem.data.pay.util
|
||||
|
||||
import android.util.Base64
|
||||
import com.tangem.utils.extensions.hexToBytes
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.KeyFactory
|
||||
import java.security.SecureRandom
|
||||
import com.tangem.common.extensions.toHexString
|
||||
import com.tangem.data.pay.entity.EncryptedData
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
|
|
@ -21,26 +20,38 @@ private const val RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"
|
|||
private const val AES_TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val AES_ALGORITHM = "AES"
|
||||
private const val RSA_ALGORITHM = "RSA"
|
||||
private const val IV_LENGTH_BYTES = 16
|
||||
private const val PIN_LENGTH = 4
|
||||
private const val PIN_LENGTH_BYTES = 8
|
||||
private const val PIN_BLOCK_ISO_9564_FORMAT_PREFIX = '2'
|
||||
private const val PIN_BLOCK_FILL_CHAR = 'F'
|
||||
|
||||
internal class RainCryptoUtil @Inject constructor(
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
|
||||
suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair<String, String> =
|
||||
suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair<ByteArray, String> =
|
||||
withContext(dispatchers.default) {
|
||||
val secretKeyHex = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }.toHexString()
|
||||
val sessionId = generateSessionId(publicKeyBase64, secretKeyHex)
|
||||
secretKeyHex to sessionId
|
||||
val secretKeyBytes = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }
|
||||
val sessionId = generateSessionId(publicKeyBase64, secretKeyBytes)
|
||||
secretKeyBytes to sessionId
|
||||
}
|
||||
|
||||
suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyHex: String): String =
|
||||
suspend fun encryptPin(pin: String, secretKeyBytes: ByteArray): EncryptedData = withContext(dispatchers.default) {
|
||||
val bytes = pinBlockByteArray(pin)
|
||||
try {
|
||||
encryptSecret(bytes, secretKeyBytes)
|
||||
} finally {
|
||||
bytes.clear()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyBytes: ByteArray): String =
|
||||
withContext(dispatchers.default) {
|
||||
val cipherTextBytes = Base64.decode(base64Secret, Base64.NO_WRAP)
|
||||
if (cipherTextBytes.size < TAG_LENGTH_BYTES) error("Cipher text too short")
|
||||
|
||||
val initializationVectorBytes = Base64.decode(base64Iv, Base64.NO_WRAP)
|
||||
val secretKeyBytes = secretKeyHex.hexToBytes()
|
||||
|
||||
val aesSecretKey = SecretKeySpec(secretKeyBytes, AES_ALGORITHM)
|
||||
val gcmParameterSpec = GCMParameterSpec(TAG_LENGTH_BYTES * BITS_PER_BYTE, initializationVectorBytes)
|
||||
|
||||
|
|
@ -51,8 +62,25 @@ internal class RainCryptoUtil @Inject constructor(
|
|||
plaintextBytes.toString(StandardCharsets.UTF_8).trim().ifEmpty { error("Invalid decrypted data") }
|
||||
}
|
||||
|
||||
private fun generateSessionId(publicKeyBase64: String, secretKeyHex: String): String {
|
||||
val secretKeyBytes = secretKeyHex.hexToBytes()
|
||||
private fun encryptSecret(bytes: ByteArray, secretKeyBytes: ByteArray): EncryptedData {
|
||||
val iv = ByteArray(IV_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }
|
||||
|
||||
val aesSecretKey = SecretKeySpec(secretKeyBytes, AES_ALGORITHM)
|
||||
val gcmSpec = GCMParameterSpec(TAG_LENGTH_BYTES * BITS_PER_BYTE, iv)
|
||||
|
||||
val cipher = Cipher.getInstance(AES_TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, aesSecretKey, gcmSpec)
|
||||
|
||||
val ciphertext = cipher.doFinal(bytes)
|
||||
bytes.clear()
|
||||
|
||||
return EncryptedData(
|
||||
encryptedBase64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP),
|
||||
ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP),
|
||||
)
|
||||
}
|
||||
|
||||
private fun generateSessionId(publicKeyBase64: String, secretKeyBytes: ByteArray): String {
|
||||
val publicKeyDerBytes = Base64.decode(publicKeyBase64, Base64.NO_WRAP)
|
||||
val publicKeySpec = X509EncodedKeySpec(publicKeyDerBytes)
|
||||
val rsaPublicKey = KeyFactory.getInstance(RSA_ALGORITHM).generatePublic(publicKeySpec)
|
||||
|
|
@ -66,4 +94,26 @@ internal class RainCryptoUtil @Inject constructor(
|
|||
|
||||
return Base64.encodeToString(cipherTextBytes, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats PIN into a PIN block using schema: [Prefix][Length][PIN][fill with F].
|
||||
* Example: 246784FFFFFFFFFF for PIN 6784.
|
||||
*/
|
||||
private fun pinBlockByteArray(pin: String): ByteArray {
|
||||
require(pin.length == PIN_LENGTH) { "PIN length must be $PIN_LENGTH" }
|
||||
require(pin.all { it.isDigit() }) { "PIN must contain digits only" }
|
||||
|
||||
val pinBlockHexLength = PIN_LENGTH_BYTES * 2
|
||||
val hex = buildString(pinBlockHexLength) {
|
||||
append(PIN_BLOCK_ISO_9564_FORMAT_PREFIX)
|
||||
append(PIN_LENGTH.toString())
|
||||
append(pin)
|
||||
while (length < pinBlockHexLength) append(PIN_BLOCK_FILL_CHAR)
|
||||
}
|
||||
return hex.toByteArray(StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun ByteArray.clear() {
|
||||
for (i in indices) this[i] = 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
enum class SetPinResult {
|
||||
SUCCESS, PIN_TOO_WEAK, DECRYPTION_ERROR, UNKNOWN_ERROR
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.tangem.domain.pay.repository
|
|||
|
||||
import arrow.core.Either
|
||||
import com.tangem.core.error.UniversalError
|
||||
import com.tangem.domain.pay.model.SetPinResult
|
||||
import com.tangem.domain.pay.model.TangemPayCardBalance
|
||||
import com.tangem.domain.pay.model.TangemPayCardDetails
|
||||
|
||||
|
|
@ -10,4 +11,6 @@ interface CardDetailsRepository {
|
|||
suspend fun getCardBalance(): Either<UniversalError, TangemPayCardBalance>
|
||||
|
||||
suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails>
|
||||
|
||||
suspend fun setPin(pin: String): Either<UniversalError, SetPinResult>
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ dependencies {
|
|||
implementation(deps.compose.ui)
|
||||
implementation(deps.compose.ui.tooling)
|
||||
implementation(deps.decompose.ext.compose)
|
||||
implementation(deps.lottie.compose)
|
||||
|
||||
/** AndroidX */
|
||||
implementation(deps.androidx.activity.compose)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
package com.tangem.features.tangempay.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.extensions.compose.stack.Children
|
||||
|
|
@ -57,18 +57,23 @@ class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor(
|
|||
componentContext: ComponentContext,
|
||||
): ComposableContentComponent = when (config) {
|
||||
TangemPayDetailsInnerRoute.Details -> TangemPayDetailsComponent(
|
||||
appComponentContext = childByContext(componentContext),
|
||||
innerRouter = innerRouter,
|
||||
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
|
||||
params = params,
|
||||
tokenReceiveComponentFactory = tokenReceiveComponentFactory,
|
||||
)
|
||||
TangemPayDetailsInnerRoute.ChangePIN -> TODO(" [REDACTED_JIRA]")
|
||||
TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent(
|
||||
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
|
||||
)
|
||||
TangemPayDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent(
|
||||
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onChildBack() {
|
||||
when (childStack.value.active.configuration) {
|
||||
TangemPayDetailsInnerRoute.ChangePIN -> stackNavigation.pop()
|
||||
TangemPayDetailsInnerRoute.Details -> router.pop()
|
||||
if (childStack.value.backStack.isEmpty()) {
|
||||
router.pop()
|
||||
} else {
|
||||
stackNavigation.pop()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
package com.tangem.features.tangempay.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
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.core.ui.security.DisableScreenshotsDisposableEffect
|
||||
import com.tangem.features.tangempay.model.TangemPayChangePinModel
|
||||
import com.tangem.features.tangempay.ui.TangemPayChangePinScreen
|
||||
|
||||
internal class TangemPayChangePinComponent(
|
||||
private val appComponentContext: AppComponentContext,
|
||||
) : AppComponentContext by appComponentContext, ComposableContentComponent {
|
||||
|
||||
private val model: TangemPayChangePinModel = getOrCreateModel()
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.uiState.collectAsStateWithLifecycle()
|
||||
BackHandler(onBack = router::pop)
|
||||
DisableScreenshotsDisposableEffect()
|
||||
TangemPayChangePinScreen(
|
||||
state = state,
|
||||
onBackClick = router::pop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.features.tangempay.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.features.tangempay.ui.TangemPayChangePinCodeSuccessScreen
|
||||
|
||||
internal class TangemPayChangePinSuccessComponent(
|
||||
private val appComponentContext: AppComponentContext,
|
||||
) : AppComponentContext by appComponentContext, ComposableContentComponent {
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
BackHandler(onBack = ::backToDetails)
|
||||
TangemPayChangePinCodeSuccessScreen(onClick = ::backToDetails)
|
||||
}
|
||||
|
||||
private fun backToDetails() {
|
||||
router.popTo(route = TangemPayDetailsInnerRoute.Details)
|
||||
}
|
||||
}
|
||||
|
|
@ -12,25 +12,23 @@ import com.tangem.core.decompose.context.AppComponentContext
|
|||
import com.tangem.core.decompose.context.child
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.core.ui.components.NavigationBar3ButtonsScrim
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent
|
||||
import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent
|
||||
import com.tangem.features.tangempay.model.TangemPayDetailsModel
|
||||
import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
|
||||
import com.tangem.features.tangempay.model.TangemPayDetailsModel
|
||||
import com.tangem.features.tangempay.ui.TangemPayDetailsScreen
|
||||
import com.tangem.features.tokenreceive.TokenReceiveComponent
|
||||
|
||||
internal class TangemPayDetailsComponent(
|
||||
private val appComponentContext: AppComponentContext,
|
||||
innerRouter: Router,
|
||||
private val params: TangemPayDetailsContainerComponent.Params,
|
||||
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
|
||||
) : AppComponentContext by appComponentContext, ComposableContentComponent {
|
||||
|
||||
private val model: TangemPayDetailsModel = getOrCreateModel(params = params, router = innerRouter)
|
||||
private val model: TangemPayDetailsModel = getOrCreateModel(params = params)
|
||||
|
||||
private val bottomSheetSlot = childSlot(
|
||||
source = model.bottomSheetNavigation,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.features.tangempay.di
|
|||
|
||||
import com.tangem.core.decompose.di.ModelComponent
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.features.tangempay.model.TangemPayChangePinModel
|
||||
import com.tangem.features.tangempay.model.TangemPayDetailsModel
|
||||
import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel
|
||||
import com.tangem.features.tangempay.model.TangemPayTxHistoryModel
|
||||
|
|
@ -29,4 +30,9 @@ internal interface TangemPayModelModule {
|
|||
@IntoMap
|
||||
@ClassKey(TangemPayTxHistoryDetailsModel::class)
|
||||
fun bindTangemPayTxHistoryDetailsModel(model: TangemPayTxHistoryDetailsModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(TangemPayChangePinModel::class)
|
||||
fun bindTangemPayChangePinModel(model: TangemPayChangePinModel): Model
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.tangem.features.tangempay.entity
|
||||
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
internal data class TangemPayChangePinUM(
|
||||
val pinCode: String,
|
||||
val error: TextReference?,
|
||||
val onPinCodeChange: (String) -> Unit,
|
||||
val submitButtonLoading: Boolean,
|
||||
val submitButtonEnabled: Boolean,
|
||||
val onSubmitClick: () -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.tangem.features.tangempay.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.domain.pay.model.SetPinResult
|
||||
import com.tangem.domain.pay.repository.CardDetailsRepository
|
||||
import com.tangem.features.tangempay.entity.TangemPayChangePinUM
|
||||
import kotlinx.coroutines.flow.*
|
||||
import com.tangem.features.tangempay.model.transformers.PinCodeChangeTransformer
|
||||
import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.transformer.update
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@Stable
|
||||
@ModelScoped
|
||||
internal class TangemPayChangePinModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val router: Router,
|
||||
private val cardDetailsRepository: CardDetailsRepository,
|
||||
) : Model() {
|
||||
|
||||
val uiState: StateFlow<TangemPayChangePinUM>
|
||||
field = MutableStateFlow(getInitialState())
|
||||
|
||||
private fun onPinCodeChange(pin: String) {
|
||||
uiState.update(transformer = PinCodeChangeTransformer(newPin = pin))
|
||||
}
|
||||
|
||||
private fun onClickSubmit() {
|
||||
modelScope.launch {
|
||||
uiState.update { it.copy(submitButtonLoading = true) }
|
||||
val result = cardDetailsRepository.setPin(uiState.value.pinCode).getOrNull()
|
||||
uiState.update { it.copy(submitButtonLoading = false) }
|
||||
when (result) {
|
||||
SetPinResult.SUCCESS -> router.push(TangemPayDetailsInnerRoute.ChangePINSuccess)
|
||||
SetPinResult.PIN_TOO_WEAK,
|
||||
SetPinResult.DECRYPTION_ERROR,
|
||||
SetPinResult.UNKNOWN_ERROR,
|
||||
null,
|
||||
-> Unit // TODO: [REDACTED_TASK_KEY] - add error handling once the requirements arrive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInitialState(): TangemPayChangePinUM {
|
||||
return TangemPayChangePinUM(
|
||||
pinCode = "",
|
||||
onPinCodeChange = ::onPinCodeChange,
|
||||
onSubmitClick = ::onClickSubmit,
|
||||
submitButtonEnabled = false,
|
||||
submitButtonLoading = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
|
|||
import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory
|
||||
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
|
||||
import com.tangem.features.tangempay.model.transformers.*
|
||||
import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute
|
||||
import com.tangem.features.tangempay.utils.TangemPayErrorMessageFactory
|
||||
import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
|
|
@ -75,7 +76,7 @@ internal class TangemPayDetailsModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun onClickChangePin() {
|
||||
// TODO [REDACTED_JIRA]
|
||||
router.push(TangemPayDetailsInnerRoute.ChangePIN)
|
||||
}
|
||||
|
||||
private fun onClickFreezeCard() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
package com.tangem.features.tangempay.model.transformers
|
||||
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.features.tangempay.details.impl.R
|
||||
import com.tangem.features.tangempay.entity.TangemPayChangePinUM
|
||||
import com.tangem.features.tangempay.utils.PinCodeValidation
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
internal class PinCodeChangeTransformer(
|
||||
private val newPin: String,
|
||||
) : Transformer<TangemPayChangePinUM> {
|
||||
|
||||
override fun transform(prevState: TangemPayChangePinUM): TangemPayChangePinUM {
|
||||
val valid = PinCodeValidation.validate(pinCode = newPin)
|
||||
// Do not show error yet if user didn't fill all 4 spaces
|
||||
val isError = PinCodeValidation.validateLength(pinCode = newPin) && !valid
|
||||
return prevState.copy(
|
||||
pinCode = newPin,
|
||||
submitButtonEnabled = valid,
|
||||
error = if (isError) {
|
||||
resourceReference(R.string.visa_onboarding_pin_validation_error_message)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,4 +10,7 @@ internal sealed class TangemPayDetailsInnerRoute : Route {
|
|||
|
||||
@Serializable
|
||||
data object ChangePIN : TangemPayDetailsInnerRoute()
|
||||
|
||||
@Serializable
|
||||
data object ChangePINSuccess : TangemPayDetailsInnerRoute()
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
package com.tangem.features.tangempay.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||
import com.airbnb.lottie.compose.animateLottieCompositionAsState
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import com.tangem.core.ui.components.*
|
||||
import com.tangem.core.ui.components.appbar.TangemTopAppBar
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.features.tangempay.details.impl.R
|
||||
|
||||
@Composable
|
||||
internal fun TangemPayChangePinCodeSuccessScreen(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val composition by rememberLottieComposition(spec = LottieCompositionSpec.RawRes(R.raw.anim_confetti))
|
||||
val progress by animateLottieCompositionAsState(composition)
|
||||
var showConfetti by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
TangemTopAppBar(
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
title = resourceReference(R.string.common_done).resolveReference(),
|
||||
startButton = null,
|
||||
titleAlignment = Alignment.CenterHorizontally,
|
||||
)
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
SuccessContent(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
)
|
||||
|
||||
PrimaryButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
.navigationBarsPadding(),
|
||||
text = stringResourceSafe(R.string.common_done),
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
if (showConfetti) {
|
||||
FullScreen(notTouchable = true) {
|
||||
LottieAnimation(
|
||||
composition = composition,
|
||||
progress = { progress },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
showConfetti = true
|
||||
}
|
||||
|
||||
LaunchedEffect(progress == 1f) {
|
||||
if (progress == 1f) {
|
||||
showConfetti = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuccessContent(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_success_blue_76),
|
||||
tint = Color.Unspecified,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(76.dp),
|
||||
)
|
||||
SpacerH32()
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
text = stringResourceSafe(R.string.tangempay_card_details_change_pin_success_title),
|
||||
style = TangemTheme.typography.h2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
SpacerH12()
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
text = stringResourceSafe(R.string.tangempay_card_details_change_pin_success_description),
|
||||
style = TangemTheme.typography.body1,
|
||||
color = TangemTheme.colors.text.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
SpacerH(72.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
TangemThemePreview {
|
||||
TangemPayChangePinCodeSuccessScreen(onClick = {})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
package com.tangem.features.tangempay.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Color.Companion.Transparent
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.components.SpacerH4
|
||||
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.tangempay.details.impl.R
|
||||
import com.tangem.features.tangempay.entity.TangemPayChangePinUM
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
internal fun TangemPayChangePinScreen(
|
||||
state: TangemPayChangePinUM,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
AppBarWithBackButton(
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 48.dp)
|
||||
.padding(horizontal = 36.dp)
|
||||
.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.visa_onboarding_pin_code_title),
|
||||
style = TangemTheme.typography.h2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
SpacerH16()
|
||||
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.visa_onboarding_pin_code_description),
|
||||
style = TangemTheme.typography.body1,
|
||||
color = TangemTheme.colors.text.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
SpacerH(26.dp)
|
||||
|
||||
PinCodeSection(state)
|
||||
}
|
||||
|
||||
NavigationPrimaryButton(
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
primaryButton = NavigationButton(
|
||||
textReference = resourceReference(R.string.common_submit),
|
||||
onClick = state.onSubmitClick,
|
||||
shouldShowProgress = state.submitButtonLoading,
|
||||
isEnabled = state.submitButtonEnabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Modifier) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
PinCode(
|
||||
modifier = modifier,
|
||||
value = state.pinCode,
|
||||
onValueChange = state.onPinCodeChange,
|
||||
focusRequester = focusRequester,
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.error != null,
|
||||
) {
|
||||
val error = remember(this) { requireNotNull(state.error) }
|
||||
Column {
|
||||
SpacerH4()
|
||||
Text(
|
||||
text = error.resolveReference(),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.warning,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(timeMillis = 300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinCode(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
numbersCount: Int = 4,
|
||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||
) {
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = { text ->
|
||||
if (text.length <= numbersCount) {
|
||||
onValueChange(text)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
.focusRequester(focusRequester)
|
||||
.clickable {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
},
|
||||
textStyle = TangemTheme.typography.h1.copy(color = Transparent),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.NumberPassword,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }),
|
||||
cursorBrush = SolidColor(Transparent),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
repeat(numbersCount) { index ->
|
||||
val digit = value.getOrNull(index)?.toString()
|
||||
PinDigitBox(
|
||||
digit = digit,
|
||||
backgroundColor = TangemTheme.colors.field.focused,
|
||||
borderColor = TangemTheme.colors.stroke.primary,
|
||||
textColor = TangemTheme.colors.text.primary1,
|
||||
textStyle = TangemTheme.typography.h1,
|
||||
)
|
||||
}
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinDigitBox(
|
||||
digit: String?,
|
||||
backgroundColor: Color,
|
||||
borderColor: Color,
|
||||
textColor: Color,
|
||||
textStyle: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(width = 48.dp, height = 64.dp)
|
||||
.background(
|
||||
color = backgroundColor,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (digit != null) {
|
||||
Text(
|
||||
text = digit,
|
||||
style = textStyle,
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.tangem.features.tangempay.utils
|
||||
|
||||
internal object PinCodeValidation {
|
||||
|
||||
private const val PIN_CODE_LENGTH = 4
|
||||
|
||||
fun validate(pinCode: String): Boolean {
|
||||
return sequenceOf(
|
||||
::validateAllDigits,
|
||||
::validateLength,
|
||||
::validateNoRepeatedDigits,
|
||||
::validateNoConsecutiveDigits,
|
||||
).all { it(pinCode) }
|
||||
}
|
||||
|
||||
fun validateLength(pinCode: String): Boolean {
|
||||
return pinCode.length == PIN_CODE_LENGTH
|
||||
}
|
||||
|
||||
private fun validateAllDigits(pinCode: String): Boolean {
|
||||
return pinCode.all { it.isDigit() }
|
||||
}
|
||||
|
||||
private fun validateNoRepeatedDigits(pinCode: String): Boolean {
|
||||
return pinCode.toSet().size > 1
|
||||
}
|
||||
|
||||
private fun validateNoConsecutiveDigits(pinCode: String): Boolean {
|
||||
return pinCode.zipWithNext().any { it.second != it.first + 1 } &&
|
||||
pinCode.zipWithNext().any { it.second != it.first - 1 }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue