Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-23 10:37:38 +03:00
parent 54ab95bb11
commit 9baa0a556b
28 changed files with 691 additions and 50 deletions

View file

@ -11,7 +11,7 @@ import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.tap.domain.hot.TangemHotSigner
import com.tangem.tap.domain.hot.TangemHotWalletSigner
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -44,7 +44,7 @@ internal object TransactionDomainModule {
transactionRepository: TransactionRepository,
walletManagersFacade: WalletManagersFacade,
singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
tangemHotSignerFactory: TangemHotSigner.Factory,
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
): SendTransactionUseCase {
return SendTransactionUseCase(
demoConfig = DemoConfig(),
@ -52,7 +52,7 @@ internal object TransactionDomainModule {
transactionRepository = transactionRepository,
walletManagersFacade = walletManagersFacade,
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
getHotSigner = tangemHotSignerFactory::create,
getHotWalletSigner = tangemHotWalletSignerFactory::create,
)
}

View file

@ -1,6 +1,9 @@
package com.tangem.tap.domain.hot
import com.tangem.common.core.TangemSdkError
import com.tangem.features.hotwallet.HotWalletPasswordRequester
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.exception.WrongPasswordException
import com.tangem.hot.sdk.model.*
import javax.inject.Inject
@ -12,33 +15,99 @@ class HotWalletAccessor @Inject constructor(
suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> {
val auth = when (hotWalletId.authType) {
HotWalletId.AuthType.NoPassword -> HotAuth.NoAuth
HotWalletId.AuthType.Password -> {
hotWalletPasswordRequester.requestPassword(hotWalletId)
}
HotWalletId.AuthType.Password -> requestPassword(false)
HotWalletId.AuthType.Biometry -> HotAuth.Biometry
}
return runCatching {
return runCatchingSdkErrors(hotWalletId, auth) {
tangemHotSdk.signHashes(
unlockHotWallet = UnlockHotWallet(
walletId = hotWalletId,
auth = auth,
auth = it,
),
dataToSign = dataToSign,
)
}.getOrElse {
if (hotWalletId.authType == HotWalletId.AuthType.Biometry) {
val passwordAuth = hotWalletPasswordRequester.requestPassword(hotWalletId)
tangemHotSdk.signHashes(
unlockHotWallet = UnlockHotWallet(
walletId = hotWalletId,
auth = passwordAuth,
),
dataToSign = dataToSign,
)
} else {
throw it
).also {
hotWalletPasswordRequester.dismiss()
}
}
}
private suspend fun <T> runCatchingSdkErrors(
hotWalletId: HotWalletId,
auth: HotAuth,
block: suspend (auth: HotAuth) -> T,
): T {
return runCatchingWrongPassInternal(
originalAuth = auth,
auth = auth,
block = { blockAuth ->
block(blockAuth).also {
// TODO [REDACTED_TASK_KEY] if user has biometry enabled, we set it as the new auth method
if (blockAuth is HotAuth.Password /*&& has biometry enabled */) {
tangemHotSdk.changeAuth(
unlockHotWallet = UnlockHotWallet(
walletId = hotWalletId,
auth = blockAuth,
),
auth = HotAuth.Biometry,
)
}
}
},
)
}
private suspend fun <T> runCatchingWrongPassInternal(
originalAuth: HotAuth,
auth: HotAuth,
block: suspend (auth: HotAuth) -> T,
): T = runCatching {
block(auth)
}.getOrElse { exception ->
if (auth is HotAuth.Biometry && exception.isBiometryError()) {
// fallback to password if biometry fails
val passAuth = requestPassword(true)
return@getOrElse runCatchingWrongPassInternal(
originalAuth = originalAuth,
auth = passAuth,
block = block,
)
}
if (exception !is WrongPasswordException) {
throw exception
}
// If the exception is a wrong password, we need to request the password again
hotWalletPasswordRequester.wrongPassword()
val passResult = requestPassword(originalAuth is HotAuth.Biometry)
runCatchingWrongPassInternal(
originalAuth = originalAuth,
auth = passResult,
block = block,
)
}
private suspend fun requestPassword(hasBiometry: Boolean): HotAuth {
return hotWalletPasswordRequester.requestPassword(hasBiometry).toAuth() ?: throw TangemSdkError.UserCancelled()
}
private fun Throwable.isBiometryError(): Boolean {
return this is TangemSdkError.AuthenticationFailed ||
this is TangemSdkError.AuthenticationCanceled ||
this is TangemSdkError.AuthenticationLockout ||
this is TangemSdkError.AuthenticationUnavailable ||
this is TangemSdkError.AuthenticationAlreadyInProgress ||
this is TangemSdkError.AuthenticationNotInitialized ||
this is TangemSdkError.AuthenticationPermanentLockout
}
private fun HotWalletPasswordRequester.Result.toAuth() = when (this) {
HotWalletPasswordRequester.Result.UseBiometry -> HotAuth.Biometry
HotWalletPasswordRequester.Result.Dismiss -> null
is HotWalletPasswordRequester.Result.EnteredPassword -> this.password
}
}

View file

@ -0,0 +1,98 @@
package com.tangem.tap.domain.hot
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.common.Wallet
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.common.map
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.hot.sdk.model.DataToSign
import com.tangem.operations.sign.SignData
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import timber.log.Timber
class TangemHotWalletSigner @AssistedInject constructor(
@Assisted private val userWallet: UserWallet.Hot,
private val hotWalletAccessor: HotWalletAccessor,
) : TransactionSigner {
override suspend fun sign(hash: ByteArray, publicKey: Wallet.PublicKey): CompletionResult<ByteArray> {
return sign(listOf(hash), publicKey).map { it.first() }
}
override suspend fun sign(
hashes: List<ByteArray>,
publicKey: Wallet.PublicKey,
): CompletionResult<List<ByteArray>> {
val wallet = userWallet.wallets.orEmpty().firstOrNull { it.publicKey.contentEquals(publicKey.seedKey) }
?: return CompletionResult.Failure(
TangemSdkError.ExceptionError(IllegalStateException("wallet is locked")),
)
val result = runCatching {
hotWalletAccessor.signHashes(
hotWalletId = userWallet.hotWalletId,
dataToSign = listOf(
DataToSign(
curve = wallet.curve,
hashes = hashes,
derivationPath = publicKey.derivationPath,
),
),
)
}.getOrElse {
Timber.e(it)
return if (it is TangemSdkError) {
CompletionResult.Failure(it)
} else {
CompletionResult.Failure(TangemSdkError.ExceptionError(it))
}
}
return CompletionResult.Success(result.map { it.signatures }.flatten())
}
override suspend fun multiSign(
dataToSign: List<SignData>,
publicKey: Wallet.PublicKey,
): CompletionResult<Map<ByteArray, ByteArray>> {
val result = runCatching {
hotWalletAccessor.signHashes(
hotWalletId = userWallet.hotWalletId,
dataToSign = dataToSign.map { signData ->
val wallet =
userWallet.wallets.orEmpty().firstOrNull { it.publicKey.contentEquals(signData.publicKey) }
?: return CompletionResult.Failure(
TangemSdkError.ExceptionError(IllegalStateException("wallet is locked")),
)
DataToSign(
curve = wallet.curve,
hashes = listOf(signData.hash),
derivationPath = signData.derivationPath,
)
},
)
}.getOrElse {
Timber.e(it)
return if (it is TangemSdkError) {
CompletionResult.Failure(it)
} else {
CompletionResult.Failure(TangemSdkError.ExceptionError(it))
}
}
return CompletionResult.Success(
result.mapIndexed { index, data ->
dataToSign[index].publicKey to data.signatures.first()
}.toMap(),
)
}
@AssistedFactory
interface Factory {
fun create(@Assisted userWallet: UserWallet.Hot): TangemHotWalletSigner
}
}

View file

@ -117,5 +117,5 @@ internal fun UserWallet.lock(): UserWallet = when (this) {
),
)
}
is UserWallet.Hot -> TODO("[REDACTED_TASK_KEY]")
is UserWallet.Hot -> copy(wallets = null)
}

View file

@ -34,6 +34,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.routing.component.RoutingComponent
import com.tangem.tap.routing.transitions.RoutingTransitionAnimationFactory
@Suppress("LongParameterList")
@OptIn(ExperimentalDecomposeApi::class)
@Composable
internal fun RootContent(
@ -41,6 +42,7 @@ internal fun RootContent(
backHandler: BackHandler,
uiDependencies: UiDependencies,
wcContent: @Composable (modifier: Modifier) -> Unit,
hotAccessCodeContent: @Composable (modifier: Modifier) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -74,6 +76,8 @@ internal fun RootContent(
wcContent(Modifier.fillMaxSize())
hotAccessCodeContent(Modifier.fillMaxSize())
TangemSnackbarHost(
modifier = Modifier
.align(Alignment.BottomCenter)

View file

@ -10,12 +10,15 @@ import com.arkivanov.essenty.lifecycle.subscribe
import com.google.android.material.snackbar.Snackbar
import com.tangem.common.routing.AppRoute
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.navigation.getOrCreateTyped
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy
import com.tangem.features.walletconnect.components.WcRoutingComponent
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.android.create
@ -41,13 +44,20 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val wcRoutingComponentFactory: WcRoutingComponent.Factory,
private val deeplinkFactory: DeepLinkFactory,
private val tangemHotSDKProxy: TangemHotSDKProxy,
private val hotAccessCodeRequestComponentFactory: HotAccessCodeRequestComponent.Factory,
private val hotAccessCodeRequesterProxy: HotWalletPasswordRequesterProxy,
) : RoutingComponent,
AppComponentContext by context,
SnackbarHandler {
private val wcRoutingComponent: WcRoutingComponent by lazy {
wcRoutingComponentFactory
.create(childByContext(componentContext = this), params = Unit)
.create(child("wcRoutingComponent"), params = Unit)
}
private val hotAccessCodeRequestComponent: HotAccessCodeRequestComponent by lazy {
hotAccessCodeRequestComponentFactory
.create(child("hotAccessCodeRequestComponent"), Unit)
}
private val stack: Value<ChildStack<AppRoute, Child>> = childStack(
@ -76,7 +86,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
}
}
configureHotSdk()
configureProxies()
}
@Composable
@ -86,6 +96,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
stack = stack,
uiDependencies = uiDependencies,
wcContent = { wcRoutingComponent.Content(it) },
hotAccessCodeContent = { hotAccessCodeRequestComponent.Content(it) },
backHandler = backHandler,
onBack = router::pop,
)
@ -127,13 +138,15 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
initialStack
}
private fun configureHotSdk() {
private fun configureProxies() {
lifecycle.subscribe(
onCreate = {
tangemHotSDKProxy.sdkState.value = TangemHotSdk.create(activity)
hotAccessCodeRequesterProxy.componentRequester.value = hotAccessCodeRequestComponent
},
onDestroy = {
tangemHotSDKProxy.sdkState.value = null
hotAccessCodeRequesterProxy.componentRequester.value = null
},
)
}

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.components
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PixelFormat
import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
import androidx.compose.runtime.*
@ -18,7 +19,12 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import java.util.UUID
@Composable
fun FullScreen(notTouchable: Boolean = false, content: @Composable (() -> Unit) -> Unit) {
fun FullScreen(
notTouchable: Boolean = false,
focusable: Boolean = false,
onBackClick: () -> Unit = {},
content: @Composable (() -> Unit) -> Unit,
) {
val view = LocalView.current
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
@ -27,7 +33,9 @@ fun FullScreen(notTouchable: Boolean = false, content: @Composable (() -> Unit)
val fullScreenLayout = remember {
FullScreenLayout(
notTouchable = notTouchable,
focusable = focusable,
composeView = view,
onBackClick = onBackClick,
uniqueId = id,
).apply {
setContent(parentComposition) {
@ -45,7 +53,9 @@ fun FullScreen(notTouchable: Boolean = false, content: @Composable (() -> Unit)
@SuppressLint("ViewConstructor", "ClickableViewAccessibility")
private class FullScreenLayout(
private val notTouchable: Boolean,
private val focusable: Boolean,
private val composeView: View,
private val onBackClick: () -> Unit,
uniqueId: UUID,
) : AbstractComposeView(composeView.context) {
@ -97,7 +107,10 @@ private class FullScreenLayout(
fun show() {
if (viewShowing) dismiss()
windowManager.addView(this, params)
params.flags = params.flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
if (focusable.not()) {
params.flags = params.flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
}
if (notTouchable) {
params.flags = params.flags or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
@ -107,6 +120,19 @@ private class FullScreenLayout(
viewShowing = true
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (focusable.not()) {
return super.dispatchKeyEvent(event)
}
if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
onBackClick()
return true
}
return super.dispatchKeyEvent(event)
}
fun dismiss() {
if (!viewShowing) return
disposeComposition()

View file

@ -38,6 +38,7 @@ fun PinTextField(
isPasswordVisual: Boolean,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
wrongCode: Boolean = false,
) {
val focusRequester = remember { FocusRequester() }
val textFieldValue = remember(value) {
@ -71,6 +72,7 @@ fun PinTextField(
CellDecoration(
length = length,
isPasswordVisual = isPasswordVisual,
wrongCode = wrongCode,
value = value,
)
},
@ -86,6 +88,7 @@ fun PinTextField(
@Composable
private fun CellDecoration(
length: Int,
wrongCode: Boolean,
value: String,
modifier: Modifier = Modifier,
isPasswordVisual: Boolean = false,
@ -128,7 +131,11 @@ private fun CellDecoration(
modifier = Modifier.sizeIn(minWidth = width.size.width.dp + 8.dp, minHeight = 48.dp),
text = text,
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
color = if (wrongCode) {
TangemTheme.colors.text.warning
} else {
TangemTheme.colors.text.primary1
},
textAlign = TextAlign.Center,
lineHeight = 48.sp,
)

View file

@ -30,7 +30,7 @@ class NetworkFactory @Inject constructor(
*
* @param blockchain blockchain
* @param extraDerivationPath extra derivation path
* @param userWallet user wallet
* @param userWallet user wallet
*/
fun create(blockchain: Blockchain, extraDerivationPath: String?, userWallet: UserWallet): Network? {
return create(
@ -52,7 +52,7 @@ class NetworkFactory @Inject constructor(
*
* @param networkId network id
* @param derivationPath derivation path
* @param userWallet user wallet
* @param userWallet user wallet
*/
fun create(networkId: Network.ID, derivationPath: Network.DerivationPath, userWallet: UserWallet): Network? {
val blockchain = networkId.toBlockchain()

View file

@ -28,4 +28,27 @@ data class MobileWallet(
chainCode = it,
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MobileWallet) return false
if (!publicKey.contentEquals(other.publicKey)) return false
if (chainCode != null) {
if (other.chainCode == null || !chainCode.contentEquals(other.chainCode)) return false
} else if (other.chainCode != null) return false
if (curve != other.curve) return false
if (derivedKeys != other.derivedKeys) return false
return true
}
override fun hashCode(): Int {
var result = publicKey.contentHashCode()
result = 31 * result + (chainCode?.contentHashCode() ?: 0)
result = 31 * result + curve.hashCode()
result = 31 * result + derivedKeys.hashCode()
return result
}
}

View file

@ -53,18 +53,8 @@ class GetCryptoCurrencyActionsUseCase(
rampStateManager = rampManager,
)
operator fun invoke(userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus): Flow<TokenActionsState> {
return when (userWallet) {
is UserWallet.Cold -> coldFlow(userWallet, cryptoCurrencyStatus)
is UserWallet.Hot -> TODO("[REDACTED_TASK_KEY]")
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun coldFlow(
userWallet: UserWallet.Cold,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): Flow<TokenActionsState> {
operator fun invoke(userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus): Flow<TokenActionsState> {
return when {
cryptoCurrencyStatus.value is CryptoCurrencyStatus.MissedDerivation -> {
flowOf(value = MissedDerivationsActionsFactory.create())

View file

@ -58,7 +58,7 @@ internal open class BaseActionsFactory(
* @param currency the cryptocurrency to check
*/
protected suspend fun getOnrampUnavailabilityReason(
userWallet: UserWallet.Cold,
userWallet: UserWallet,
currency: CryptoCurrency,
): ScenarioUnavailabilityReason {
return rampStateManager.availableForBuy(

View file

@ -35,7 +35,7 @@ internal class CommonActionsFactory(
* @param shouldShowSwapStories a flag indicating whether to show swap stories
*/
suspend fun create(
userWallet: UserWallet.Cold,
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
stakingAvailability: StakingAvailability,
shouldShowSwapStories: Boolean,

View file

@ -33,7 +33,7 @@ internal class OutdatedDataActionsFactory(
* @param stakingAvailability the staking availability for the cryptocurrency
*/
suspend fun create(
userWallet: UserWallet.Cold,
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
stakingAvailability: StakingAvailability,
): Set<ActionState> = coroutineScope {

View file

@ -22,7 +22,7 @@ internal class UnreachableActionsFactory(
rampStateManager: RampStateManager,
) : BaseActionsFactory(walletManagersFacade, rampStateManager) {
suspend fun create(userWallet: UserWallet.Cold, cryptoCurrencyStatus: CryptoCurrencyStatus): Set<ActionState> =
suspend fun create(userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus): Set<ActionState> =
coroutineScope {
val isAddressAvailable = isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)

View file

@ -32,7 +32,7 @@ class SendTransactionUseCase(
private val transactionRepository: TransactionRepository,
private val walletManagersFacade: WalletManagersFacade,
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
private val getHotSigner: (UserWallet.Hot) -> TransactionSigner,
private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner,
) {
suspend operator fun invoke(
txsData: List<TransactionData>,
@ -53,7 +53,7 @@ class SendTransactionUseCase(
coldSigner
}
is UserWallet.Hot -> {
getHotSigner(userWallet)
getHotWalletSigner(userWallet)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.domain.wallets.builder
import com.tangem.blockchain.blockchains.cardano.CardanoUtils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.domain.models.MobileWallet
@ -24,11 +25,19 @@ class HotUserWalletBuilder @AssistedInject constructor(
) {
suspend fun build(): UserWallet.Hot = withContext(dispatcherProvider.default) {
val allNetworks = Blockchain.entries // TODO use HotDerivationsRepository to get supported networks
val allNetworks = Blockchain.entries
val curves = allNetworks.map { it.getSupportedCurves() }.flatten().toSet()
val requests = curves.sortedBy { it.ordinal }.map { curve ->
val derivationPaths = allNetworks.filter { curve in it.getSupportedCurves() }
.mapNotNull { it.derivationPath(DerivationStyle.V3) }
.mapNotNull {
val derivationPath = it.derivationPath(DerivationStyle.V3) ?: return@mapNotNull null
if (it == Blockchain.Cardano) {
val extendedDerivationPath = CardanoUtils.extendedDerivationPath(derivationPath)
listOf(derivationPath, extendedDerivationPath)
} else {
listOf(derivationPath)
}
}.flatten()
DeriveWalletRequest.Request(
curve = curve,

View file

@ -23,4 +23,7 @@ dependencies {
/* Compose */
implementation(deps.compose.runtime)
/* Tangem libs */
implementation(tangemDeps.hot.core)
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.hotwallet
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface HotAccessCodeRequestComponent : ComposableContentComponent, HotWalletPasswordRequester {
interface Factory : ComponentFactory<Unit, HotAccessCodeRequestComponent>
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.hotwallet
import com.tangem.hot.sdk.model.HotAuth
interface HotWalletPasswordRequester {
suspend fun wrongPassword()
suspend fun requestPassword(hasBiometry: Boolean): Result
suspend fun dismiss()
sealed class Result {
data object UseBiometry : Result()
data object Dismiss : Result()
data class EnteredPassword(val password: HotAuth.Password) : Result()
}
}

View file

@ -31,6 +31,7 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.models)
/** Common */
implementation(projects.common.ui)

View file

@ -0,0 +1,70 @@
package com.tangem.features.hotwallet.accesscoderequest
import androidx.compose.foundation.focusable
import androidx.compose.runtime.*
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.components.FullScreen
import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
import com.tangem.features.hotwallet.HotWalletPasswordRequester
import com.tangem.features.hotwallet.accesscoderequest.ui.HotAccessCodeRequestFullScreenContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.delay
internal class DefaultHotAccessCodeRequestComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Unit,
) : AppComponentContext by appComponentContext, HotAccessCodeRequestComponent {
private val model: HotAccessCodeRequestModel = getOrCreateModel(params)
override suspend fun wrongPassword() {
model.wrongAccessCode()
}
override suspend fun requestPassword(hasBiometry: Boolean): HotWalletPasswordRequester.Result {
model.show(hasBiometry)
return model.waitResult()
}
override suspend fun dismiss() {
model.dismiss()
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
var isShownProxy by remember { mutableStateOf(state.isShown) }
var isShownIfProxy by remember { mutableStateOf(state.isShown) }
if (isShownIfProxy) {
FullScreen(focusable = true, onBackClick = state.onDismiss) {
HotAccessCodeRequestFullScreenContent(
state = state.copy(isShown = isShownProxy),
modifier = modifier,
)
}
}
LaunchedEffect(state.isShown) {
if (state.isShown) {
isShownIfProxy = true
delay(timeMillis = 100)
isShownProxy = true
} else {
isShownProxy = false
delay(timeMillis = 250)
isShownIfProxy = false
}
}
}
@AssistedFactory
interface Factory : HotAccessCodeRequestComponent.Factory {
override fun create(context: AppComponentContext, params: Unit): DefaultHotAccessCodeRequestComponent
}
}

View file

@ -0,0 +1,89 @@
package com.tangem.features.hotwallet.accesscoderequest
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.HotWalletPasswordRequester
import com.tangem.features.hotwallet.accesscoderequest.entity.HotAccessCodeRequestUM
import com.tangem.hot.sdk.model.HotAuth
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@ModelScoped
internal class HotAccessCodeRequestModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val result = MutableStateFlow<HotWalletPasswordRequester.Result?>(null)
val uiState: StateFlow<HotAccessCodeRequestUM>
field = MutableStateFlow(getInitialState())
fun dismiss() {
result.value = HotWalletPasswordRequester.Result.Dismiss
dismissState()
}
fun show(hasBiometry: Boolean) {
result.value = null // Reset the result when showing the dialog
uiState.update {
it.copy(
isShown = true,
accessCode = "",
useBiometricVisible = hasBiometry,
onAccessCodeChange = ::onAccessCodeChange,
)
}
}
suspend fun waitResult(): HotWalletPasswordRequester.Result {
return result.filterNotNull().first().also { result.value = null }
}
suspend fun wrongAccessCode() {
uiState.update {
it.copy(
wrongAccessCode = true,
onAccessCodeChange = {},
)
}
delay(timeMillis = 500) // Delay to show the wrong access code state
}
private fun getInitialState() = HotAccessCodeRequestUM(
onDismiss = ::dismiss,
onAccessCodeChange = ::onAccessCodeChange,
accessCode = "",
useBiometricClick = {
dismissState()
result.value = HotWalletPasswordRequester.Result.UseBiometry
},
)
private fun onAccessCodeChange(accessCode: String) {
if (accessCode.length > ACCESS_CODE_LENGTH) return
uiState.update {
it.copy(accessCode = accessCode, wrongAccessCode = false)
}
if (accessCode.length == ACCESS_CODE_LENGTH) {
uiState.update {
it.copy(onAccessCodeChange = {})
}
result.value = HotWalletPasswordRequester.Result.EnteredPassword(HotAuth.Password(accessCode.toCharArray()))
}
}
private fun dismissState() {
uiState.update {
it.copy(isShown = false)
}
}
private companion object {
const val ACCESS_CODE_LENGTH = 6
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.hotwallet.accesscoderequest.di
import com.tangem.core.decompose.model.Model
import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
import com.tangem.features.hotwallet.HotWalletPasswordRequester
import com.tangem.features.hotwallet.accesscoderequest.DefaultHotAccessCodeRequestComponent
import com.tangem.features.hotwallet.accesscoderequest.HotAccessCodeRequestModel
import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModuleBinds {
@Binds
fun bindComponentFactory(impl: DefaultHotAccessCodeRequestComponent.Factory): HotAccessCodeRequestComponent.Factory
@Binds
@IntoMap
@ClassKey(HotAccessCodeRequestModel::class)
fun bindCreateMobileWalletModel(model: HotAccessCodeRequestModel): Model
@Binds
@Singleton
fun bindHotWalletPasswordRequester(impl: HotWalletPasswordRequesterProxy): HotWalletPasswordRequester
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.hotwallet.accesscoderequest.entity
internal data class HotAccessCodeRequestUM(
val isShown: Boolean = false,
val accessCode: String = "",
val wrongAccessCode: Boolean = false,
val useBiometricVisible: Boolean = true,
val useBiometricClick: () -> Unit = {},
val onAccessCodeChange: (String) -> Unit = {},
val onDismiss: () -> Unit = {},
)

View file

@ -0,0 +1,32 @@
package com.tangem.features.hotwallet.accesscoderequest.proxy
import com.tangem.features.hotwallet.HotWalletPasswordRequester
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HotWalletPasswordRequesterProxy @Inject constructor() : HotWalletPasswordRequester {
val componentRequester = MutableStateFlow<HotWalletPasswordRequester?>(null)
override suspend fun wrongPassword() {
call { wrongPassword() }
}
override suspend fun requestPassword(hasBiometry: Boolean): HotWalletPasswordRequester.Result =
call { requestPassword(hasBiometry) }
override suspend fun dismiss() {
call { dismiss() }
}
private suspend fun <T> call(block: suspend HotWalletPasswordRequester.() -> T): T {
return withTimeout(timeMillis = 1000) {
componentRequester.filterNotNull().first()
}.block()
}
}

View file

@ -0,0 +1,137 @@
package com.tangem.features.hotwallet.accesscoderequest.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.fields.PinTextField
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.hotwallet.accesscoderequest.entity.HotAccessCodeRequestUM
import com.tangem.features.hotwallet.impl.R
@Suppress("MagicNumber", "LongMethod")
@Composable
internal fun HotAccessCodeRequestFullScreenContent(state: HotAccessCodeRequestUM, modifier: Modifier = Modifier) {
AnimatedVisibility(
modifier = modifier,
visible = state.isShown,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemTopAppBar(
modifier = Modifier
.statusBarsPadding(),
startButton = TopAppBarButtonUM.Back(state.onDismiss),
)
SpacerH(68.dp)
Column(
Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier.animateEnterExit(
enter = slideInVertically(
tween(),
initialOffsetY = { it + 200 },
) + fadeIn(tween()),
exit = slideOutVertically(tween(300)) { it - 200 } + fadeOut(tween()),
),
text = stringResourceSafe(R.string.access_code_check_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
)
SpacerH24()
PinTextField(
modifier = Modifier.animateEnterExit(
enter = slideInVertically(
tween(),
initialOffsetY = { it + 200 },
) + fadeIn(tween()),
exit = slideOutVertically(tween(300)) { it - 200 } + fadeOut(tween()),
),
length = 6,
isPasswordVisual = true,
value = state.accessCode,
wrongCode = state.wrongAccessCode,
onValueChange = state.onAccessCodeChange,
)
}
if (state.useBiometricVisible) {
SecondaryButton(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.navigationBarsPadding()
.imePadding(),
text = "Use biometric",
onClick = state.useBiometricClick,
)
}
}
}
val hapticManager = LocalHapticManager.current
LaunchedEffect(state.wrongAccessCode) {
if (state.wrongAccessCode) {
hapticManager.perform(TangemHapticEffect.View.Reject)
}
}
}
@Preview(showBackground = true)
@Composable
private fun Preview() {
TangemThemePreview {
Box(
Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.secondary),
) {
var isShown by remember { mutableStateOf(true) }
HotAccessCodeRequestFullScreenContent(
state = HotAccessCodeRequestUM(isShown = isShown),
modifier = Modifier,
)
Button(onClick = { isShown = !isShown }) {
Text("TOggle")
}
}
}
}

View file

@ -11,7 +11,7 @@ tangemCardSdk = "develop-501"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
tangemVico = "2.0.0-alpha.25-tangem12"
#tangemVico = "0.0.1" # Keep it! - used for local builds ^
tangemHotSdk = "develop-438"
tangemHotSdk = "develop-441"
#tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^