From a04992591a778153ecf38ff3e55b49140f0f2a51 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 27 Oct 2025 13:07:28 +0400 Subject: [PATCH] Updated on 2026-08-14 --- app/build.gradle.kts | 4 +- .../configs/feature_toggles_config.json | 2 +- .../tangem/datasource/api/pay/TangemPayApi.kt | 7 + .../api/pay/models/request/SetPinRequest.kt | 11 + .../api/pay/models/request/SetPinResponse.kt | 15 ++ .../tangem/data/pay/entity/EncryptedData.kt | 6 + .../DefaultCardDetailsRepository.kt | 35 ++- .../tangem/data/pay/util/RainCryptoUtil.kt | 72 +++++- .../tangem/domain/pay/model/SetPinResult.kt | 5 + .../pay/repository/CardDetailsRepository.kt | 3 + .../tangempay/details/impl/build.gradle.kts | 1 + ...faultTangemPayDetailsContainerComponent.kt | 19 +- .../components/TangemPayChangePinComponent.kt | 31 +++ .../TangemPayChangePinSuccessComponent.kt | 24 ++ .../components/TangemPayDetailsComponent.kt | 6 +- .../tangempay/di/TangemPayModelModule.kt | 6 + .../tangempay/entity/TangemPayChangePinUM.kt | 12 + .../model/TangemPayChangePinModel.kt | 61 +++++ .../tangempay/model/TangemPayDetailsModel.kt | 3 +- .../transformers/PinCodeChangeTransformer.kt | 27 +++ .../navigation/TangemPayDetailsInnerRoute.kt | 3 + .../ui/TangemPayChangePinCodeSuccessScreen.kt | 127 ++++++++++ .../tangempay/ui/TangemPayChangePinScreen.kt | 224 ++++++++++++++++++ .../tangempay/utils/PinCodeValidation.kt | 32 +++ 24 files changed, 707 insertions(+), 29 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinResponse.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/entity/EncryptedData.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/model/SetPinResult.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayChangePinUM.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/PinCodeChangeTransformer.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PinCodeValidation.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 48d6fc0098..042bf40240 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 675625e1a8..73c8739caa 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -33,7 +33,7 @@ }, { "name": "TANGEM_PAY_ENABLED", - "version": "5.30.0" + "version": "undefined" }, { "name": "NEW_TOKEN_RECEIVE_ENABLED", diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 7622d4f825..bb456d3a78 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -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 + + @PUT("v1/customer/card/pin") + suspend fun setPin( + @Header("Authorization") authHeader: String, + @Body body: SetPinRequest, + ): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinRequest.kt new file mode 100644 index 0000000000..f5052e66f0 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinRequest.kt @@ -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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinResponse.kt new file mode 100644 index 0000000000..8577be889c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/SetPinResponse.kt @@ -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, + ) +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/EncryptedData.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/EncryptedData.kt new file mode 100644 index 0000000000..cdbe85d100 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/EncryptedData.kt @@ -0,0 +1,6 @@ +package com.tangem.data.pay.entity + +internal data class EncryptedData( + val encryptedBase64: String, + val ivBase64: String, +) \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt index 2875b51a63..6c820d0bd1 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt @@ -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 { 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 { + 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) { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt index 24b60d6ea8..4fa2681263 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt @@ -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 = + suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair = 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 + } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/SetPinResult.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/SetPinResult.kt new file mode 100644 index 0000000000..7631289549 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/SetPinResult.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.pay.model + +enum class SetPinResult { + SUCCESS, PIN_TOO_WEAK, DECRYPTION_ERROR, UNKNOWN_ERROR +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CardDetailsRepository.kt index 39b0e8edfa..63211bc2c1 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CardDetailsRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/CardDetailsRepository.kt @@ -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 suspend fun revealCardDetails(): Either + + suspend fun setPin(pin: String): Either } \ No newline at end of file diff --git a/features/tangempay/details/impl/build.gradle.kts b/features/tangempay/details/impl/build.gradle.kts index d3d42f6a91..f026e5a44f 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -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) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index 0c8c21c4cc..28d4475d31 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -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() } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinComponent.kt new file mode 100644 index 0000000000..35ae97b213 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinComponent.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt new file mode 100644 index 0000000000..d47fcd6737 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt @@ -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) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index 3f46e8eba1..32fe13d588 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -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, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index cc9f121abb..8ba6b15a36 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -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 } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayChangePinUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayChangePinUM.kt new file mode 100644 index 0000000000..ae44a7164e --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayChangePinUM.kt @@ -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, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt new file mode 100644 index 0000000000..223519f8f0 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt @@ -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 + 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, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index d19ebecc39..e70c799444 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -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() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/PinCodeChangeTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/PinCodeChangeTransformer.kt new file mode 100644 index 0000000000..4b7030b2a7 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/PinCodeChangeTransformer.kt @@ -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 { + + 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 + }, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt index 9aa69d5a1f..9aabeac0cd 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt @@ -10,4 +10,7 @@ internal sealed class TangemPayDetailsInnerRoute : Route { @Serializable data object ChangePIN : TangemPayDetailsInnerRoute() + + @Serializable + data object ChangePINSuccess : TangemPayDetailsInnerRoute() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt new file mode 100644 index 0000000000..d1c902a313 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt @@ -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 = {}) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt new file mode 100644 index 0000000000..f052057177 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt @@ -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, + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PinCodeValidation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PinCodeValidation.kt new file mode 100644 index 0000000000..1656b21082 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PinCodeValidation.kt @@ -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 } + } +} \ No newline at end of file