Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-13 11:34:30 +03:00
parent de3bdb3cb0
commit 56164d0b8b
36 changed files with 255 additions and 124 deletions

1
features/biometry/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,15 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.biometry"
}
dependencies {
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.biometry
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
interface AskBiometryComponent : ComposableContentComponent, ComposableBottomSheetComponent {
data class Params(
val bottomSheetVariant: Boolean,
val modelCallbacks: ModelCallbacks,
)
interface ModelCallbacks {
fun onAllowed()
fun onDenied()
}
interface Factory : ComponentFactory<Params, AskBiometryComponent>
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.biometry
interface BiometryFeatureToggles {
val isAskForBiometryEnabled: Boolean
}

1
features/biometry/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,52 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.biometry.impl"
}
dependencies {
api(projects.features.biometry.api)
/** Core modules */
implementation(projects.core.ui)
implementation(projects.core.res)
implementation(projects.core.decompose)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.configToggles)
implementation(projects.core.navigation)
/** Domain */
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.settings)
implementation(projects.domain.card)
/** Compose libraries */
implementation(deps.compose.material3)
implementation(deps.compose.animation)
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
/** Tangem libraries */
implementation(projects.libs.tangemSdkApi)
implementation(tangemDeps.card.core)
implementation(tangemDeps.card.android) {
exclude(module = "joda-time")
}
/** Other */
implementation(deps.timber)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,76 @@
package com.tangem.features.biometry.impl
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.essenty.instancekeeper.getOrCreateSimple
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.biometry.impl.model.AskBiometryModel
import com.tangem.features.biometry.impl.ui.AskBiometry
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
internal class DefaultAskBiometryComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: AskBiometryComponent.Params,
) : AskBiometryComponent, AppComponentContext by appComponentContext {
private val model: AskBiometryModel = getOrCreateModel(params)
private val bsShown = instanceKeeper.getOrCreateSimple { MutableStateFlow(true) }
init {
model.dismissBSFlow
.onEach { bsShown.value = false }
.launchIn(componentScope)
}
override fun dismiss() = model.dismiss()
@Composable
override fun BottomSheet() {
val state by model.uiState.collectAsStateWithLifecycle()
val bsShown by bsShown.collectAsStateWithLifecycle()
val bsConfig = remember(this, bsShown) {
TangemBottomSheetConfig(
isShown = bsShown,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)
}
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = bsConfig,
content = { AskBiometry(state) },
)
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
AskBiometry(
modifier = modifier.navigationBarsPadding(),
state = state,
)
}
@AssistedFactory
interface Factory : AskBiometryComponent.Factory {
override fun create(
context: AppComponentContext,
params: AskBiometryComponent.Params,
): DefaultAskBiometryComponent
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.biometry.impl
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.biometry.BiometryFeatureToggles
import javax.inject.Inject
internal class DefaultBiometryFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : BiometryFeatureToggles {
override val isAskForBiometryEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("ASK_BIOMETRY_REFACTORING_ENABLED")
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.biometry.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.biometry.BiometryFeatureToggles
import com.tangem.features.biometry.impl.DefaultAskBiometryComponent
import com.tangem.features.biometry.impl.DefaultBiometryFeatureToggles
import com.tangem.features.biometry.impl.model.AskBiometryModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(SingletonComponent::class)
internal interface FeatureModule {
@Binds
fun bindFeatureToggles(impl: DefaultBiometryFeatureToggles): BiometryFeatureToggles
@Binds
fun bindComponentFactory(impl: DefaultAskBiometryComponent.Factory): AskBiometryComponent.Factory
}
@Module
@InstallIn(ModelComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(AskBiometryModel::class)
fun provideModel(model: AskBiometryModel): Model
}

View file

@ -0,0 +1,143 @@
package com.tangem.features.biometry.impl.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.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.settings.SetSaveWalletScreenShownUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.biometry.impl.ui.state.AskBiometryUM
import com.tangem.sdk.api.TangemSdkManager
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.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@Stable
@ModelScoped
internal class AskBiometryModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val setSaveWalletScreenShownUseCase: SetSaveWalletScreenShownUseCase,
private val settingsRepository: SettingsRepository,
private val tangemSdkManager: TangemSdkManager,
private val userWalletsListManager: UserWalletsListManager,
private val walletsRepository: WalletsRepository,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val settingsManager: SettingsManager,
private val uiMessageSender: UiMessageSender,
) : Model() {
private val params = paramsContainer.require<AskBiometryComponent.Params>()
private val _uiState = MutableStateFlow(
AskBiometryUM(
bottomSheetVariant = params.bottomSheetVariant,
onAllowClick = ::onAllowClick,
onDontAllowClick = ::dontAllow,
onDismiss = ::dismiss,
),
)
val uiState = _uiState.asStateFlow()
val dismissBSFlow = MutableSharedFlow<Unit>()
init {
modelScope.launch {
setSaveWalletScreenShownUseCase()
}
}
private fun onAllowClick() {
_uiState.update { it.copy(showProgress = true) }
allowToUseBiometrics()
}
private fun dontAllow() {
params.modelCallbacks.onDenied()
}
fun dismiss() {
modelScope.launch {
dismissBSFlow.emit(Unit)
delay(timeMillis = 500)
dontAllow()
}
}
private fun allowToUseBiometrics() {
modelScope.launch {
if (tangemSdkManager.checkNeedEnrollBiometrics()) {
showEnrollBiometricsDialog()
return@launch
}
/*
* because it will be automatically saved on UserWalletsListManager switch
*/
val selectedUserWallet = userWalletsListManager.selectedUserWalletSync ?: run {
Timber.e("Unable to save user wallet")
uiMessageSender.send(
SnackbarMessage(stringReference("No selected user wallet")),
)
return@launch
}
handleSuccessAllowing(selectedUserWallet)
}
}
private suspend fun handleSuccessAllowing(userWallet: UserWallet) {
walletsRepository.saveShouldSaveUserWallets(item = true)
settingsRepository.setShouldSaveAccessCodes(value = true)
cardSdkConfigRepository.setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = userWallet.hasAccessCode,
)
if (_uiState.value.bottomSheetVariant) {
dismissBSFlow.emit(Unit)
delay(timeMillis = 500)
}
params.modelCallbacks.onAllowed()
}
private fun showEnrollBiometricsDialog() {
uiMessageSender.send(
DialogMessage(
message = resourceReference(R.string.save_user_wallet_agreement_enroll_biometrics_description),
title = resourceReference(R.string.save_user_wallet_agreement_enroll_biometrics_title),
firstAction = EventMessageAction(
title = resourceReference(R.string.common_enable),
onClick = { settingsManager.openBiometricSettings() },
),
secondAction = EventMessageAction(
title = resourceReference(R.string.common_cancel),
onClick = {},
),
dismissOnFirstAction = true,
),
)
}
}

View file

@ -0,0 +1,205 @@
package com.tangem.features.biometry.impl.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.*
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.biometry.impl.ui.state.AskBiometryUM
import com.tangem.features.biometry.R
@Composable
internal fun AskBiometry(state: AskBiometryUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier.weight(1f),
) {
if (state.bottomSheetVariant) {
Header(onCloseClick = state.onDismiss)
}
SpacerH32()
SpacerHHalf()
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
Title(modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing56))
SpacerH32()
Description(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing34)
.fillMaxWidth(),
)
}
SpacerHHalf()
}
Footer(state = state)
SpacerH16()
}
}
@Composable
private fun Header(onCloseClick: () -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(
modifier = Modifier.size(TangemTheme.dimens.size32),
onClick = onCloseClick,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size24),
painter = painterResource(id = R.drawable.ic_close_24),
tint = TangemTheme.colors.icon.secondary,
contentDescription = stringResourceSafe(id = R.string.common_cancel),
)
}
SpacerW8()
}
}
@Composable
private fun Title(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing32),
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size56),
painter = painterResource(id = R.drawable.ic_fingerprint_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
Text(
text = stringResourceSafe(id = R.string.save_user_wallet_agreement_header_biometrics),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun Description(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24),
horizontalAlignment = Alignment.Start,
) {
DescriptionItem(
iconPainter = painterResource(id = R.drawable.ic_face_recognition_24),
title = stringResourceSafe(id = R.string.save_user_wallet_agreement_access_title),
description = stringResourceSafe(id = R.string.save_user_wallet_agreement_access_description),
)
DescriptionItem(
iconPainter = painterResource(id = R.drawable.ic_lock_24),
title = stringResourceSafe(id = R.string.save_user_wallet_agreement_code_title),
description = stringResourceSafe(id = R.string.save_user_wallet_agreement_code_description_biometrics),
)
}
}
@Composable
private fun Footer(state: AskBiometryUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
showProgress = state.showProgress,
text = stringResourceSafe(id = R.string.save_user_wallet_agreement_allow_biometrics),
onClick = state.onAllowClick,
)
if (state.bottomSheetVariant.not()) {
SpacerH12()
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(id = R.string.save_user_wallet_agreement_dont_allow),
onClick = state.onDontAllowClick,
)
}
SpacerH16()
Text(
modifier = Modifier.fillMaxWidth(fraction = .7f),
text = stringResourceSafe(R.string.save_user_wallet_agreement_notice),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun DescriptionItem(iconPainter: Painter, title: String, description: String) {
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size24),
painter = iconPainter,
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
SpacerW24()
Column {
Text(
text = title,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
SpacerH4()
Text(
text = description,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun Preview() {
TangemThemePreview {
AskBiometry(
state = AskBiometryUM(),
)
}
}
@Preview(showBackground = true)
@Composable
private fun PreviewBS() {
TangemThemePreview {
AskBiometry(
state = AskBiometryUM(bottomSheetVariant = true),
)
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.biometry.impl.ui.state
import com.tangem.core.ui.extensions.TextReference
internal data class AskBiometryUM(
val bottomSheetVariant: Boolean = false,
val showProgress: Boolean = false,
val error: TextReference? = null,
val onAllowClick: () -> Unit = {},
val onDontAllowClick: () -> Unit = {},
val onDismiss: () -> Unit = {},
)