Updated on 2026-08-14
This commit is contained in:
parent
54ab95bb11
commit
9baa0a556b
28 changed files with 691 additions and 50 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -117,5 +117,5 @@ internal fun UserWallet.lock(): UserWallet = when (this) {
|
|||
),
|
||||
)
|
||||
}
|
||||
is UserWallet.Hot -> TODO("[REDACTED_TASK_KEY]")
|
||||
is UserWallet.Hot -> copy(wallets = null)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -23,4 +23,7 @@ dependencies {
|
|||
|
||||
/* Compose */
|
||||
implementation(deps.compose.runtime)
|
||||
|
||||
/* Tangem libs */
|
||||
implementation(tangemDeps.hot.core)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ^
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue