Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-08 14:09:57 +03:00
parent 52a607c1f5
commit 9944036c4d
29 changed files with 874 additions and 60 deletions

View file

@ -0,0 +1,78 @@
package com.tangem.tap.di.domain
import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase
import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.GetDynamicAddressesStatusUseCase
import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase
import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.DerivationsRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object DynamicAddressesDomainModule {
@Provides
@Singleton
fun provideEnableDynamicAddressesUseCase(
dynamicAddressesRepository: DynamicAddressesRepository,
): EnableDynamicAddressesUseCase {
return EnableDynamicAddressesUseCase(dynamicAddressesRepository)
}
@Provides
@Singleton
fun provideDisableDynamicAddressesUseCase(
dynamicAddressesRepository: DynamicAddressesRepository,
): DisableDynamicAddressesUseCase {
return DisableDynamicAddressesUseCase(dynamicAddressesRepository)
}
@Provides
@Singleton
fun provideGetDynamicAddressesStatusUseCase(
dynamicAddressesRepository: DynamicAddressesRepository,
): GetDynamicAddressesStatusUseCase {
return GetDynamicAddressesStatusUseCase(dynamicAddressesRepository)
}
@Provides
@Singleton
fun provideGetDynamicReceiveAddressUseCase(
dynamicAddressesRepository: DynamicAddressesRepository,
): GetDynamicReceiveAddressUseCase {
return GetDynamicReceiveAddressUseCase(dynamicAddressesRepository)
}
@Provides
@Singleton
fun provideCreateConsolidationTransactionUseCase(
consolidationRepository: ConsolidationRepository,
): CreateConsolidationTransactionUseCase {
return CreateConsolidationTransactionUseCase(consolidationRepository)
}
@Provides
@Singleton
fun provideIsXpubSupportedUseCase(walletManagersFacade: WalletManagersFacade): IsXpubSupportedUseCase {
return IsXpubSupportedUseCase(walletManagersFacade)
}
@Provides
@Singleton
fun provideIsXpubDerivedUseCase(
walletManagersFacade: WalletManagersFacade,
derivationsRepository: DerivationsRepository,
): IsXpubDerivedUseCase {
return IsXpubDerivedUseCase(walletManagersFacade, derivationsRepository)
}
}

View file

@ -33,7 +33,7 @@ class FinalizeTwinTask(
visaCardScanHandler = null,
visaCoroutineScope = null,
shouldCheckIsAlreadyActivated = false,
isDynamicAddressesEnabled = false,
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
onboardingV2FeatureToggles = null,
).run(session, callback)
is CompletionResult.Failure ->

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M11.999,1.25C13.68,1.25 15.047,1.818 16.24,2.436C16.6,2.622 16.935,2.807 17.255,2.983C18.038,3.415 18.733,3.797 19.495,4.04C19.942,4.183 20.338,4.31 20.619,4.42C20.883,4.523 21.198,4.665 21.42,4.913C21.621,5.136 21.723,5.385 21.792,5.624C21.854,5.841 21.907,6.135 21.958,6.417C23.196,13.282 20.492,19.787 13.881,22.318C13.232,22.567 12.753,22.75 12.002,22.75C11.251,22.75 10.772,22.567 10.123,22.318C3.512,19.788 0.806,13.283 2.043,6.417C2.094,6.135 2.147,5.841 2.209,5.624C2.277,5.385 2.38,5.135 2.58,4.913C2.802,4.665 3.117,4.524 3.381,4.42C3.662,4.31 4.059,4.183 4.506,4.04C5.268,3.797 5.961,3.415 6.744,2.983C7.063,2.807 7.398,2.622 7.758,2.436C8.95,1.818 10.317,1.25 11.999,1.25ZM17.942,8.666C17.757,8.146 17.186,7.873 16.666,8.058C15.788,8.369 14.948,8.961 14.212,9.607C13.465,10.262 12.766,11.023 12.172,11.734C11.733,12.261 11.345,12.769 11.03,13.199C10.741,12.854 10.453,12.601 10.179,12.418C9.905,12.235 9.552,12 9,12C8.447,12 8,12.448 8,13C8,13.521 8.397,13.949 8.906,13.996C9.11,14.015 9.664,14.566 10.105,15.447C10.266,15.77 10.589,15.981 10.949,15.999C11.309,16.017 11.65,15.84 11.843,15.536C11.849,15.528 12.193,15.012 12.357,14.78C12.688,14.311 13.157,13.677 13.708,13.016C14.262,12.352 14.887,11.675 15.53,11.111C16.183,10.539 16.8,10.132 17.333,9.943C17.854,9.759 18.126,9.187 17.942,8.666Z"
android:fillColor="#0099FF"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="72dp"
android:height="72dp"
android:viewportWidth="72"
android:viewportHeight="72">
<group>
<clip-path
android:pathData="M36,0L36,0A36,36 0,0 1,72 36L72,36A36,36 0,0 1,36 72L36,72A36,36 0,0 1,0 36L0,36A36,36 0,0 1,36 0z"/>
<path
android:pathData="M0,0h72v72h-72z"
android:strokeAlpha="0.1"
android:fillColor="#0099FF"
android:fillAlpha="0.1"/>
<path
android:pathData="M24.607,31.142C23.778,32.206 22.682,31.815 22.291,30.454L20.319,23.678C20.006,22.598 20.663,21.753 21.79,21.8L28.848,22.035C30.256,22.081 30.882,23.067 30.069,24.116L28.66,25.915L28.723,25.947C31.602,27.856 35.123,31.831 35.968,34.46H36.031C36.86,31.846 40.397,27.856 43.276,25.947L43.354,25.9L41.915,23.912C41.132,22.833 41.774,21.862 43.198,21.878L50.256,21.847C51.382,21.847 52.008,22.723 51.664,23.803L49.473,30.501C49.051,31.846 47.924,32.206 47.126,31.095L45.608,28.998L45.264,29.217C42.118,31.22 37.893,36.15 37.893,39.734V48.106C37.893,49.467 37.22,50.203 36,50.203C34.779,50.203 34.122,49.467 34.122,48.106V39.734C34.122,36.15 29.865,31.22 26.735,29.217L26.313,28.951L24.607,31.142Z"
android:fillColor="#0099FF"/>
</group>
</vector>

View file

@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="56dp"
android:height="56dp"
android:viewportWidth="56"
android:viewportHeight="56">
<group>
<clip-path
android:pathData="M28,0L28,0A28,28 0,0 1,56 28L56,28A28,28 0,0 1,28 56L28,56A28,28 0,0 1,0 28L0,28A28,28 0,0 1,28 0z"/>
<path
android:pathData="M0,0h56v56h-56z"
android:strokeAlpha="0.1"
android:fillColor="#FFB71B"
android:fillAlpha="0.1"/>
<path
android:pathData="M41.646,35.377C41.646,37.37 40.305,38.906 38.091,38.906H17.909C15.696,38.906 14.354,37.37 14.354,35.377C14.354,34.765 14.511,34.14 14.862,33.555L24.966,15.95C25.631,14.766 26.802,14.167 28,14.167C29.198,14.167 30.357,14.766 31.034,15.95L41.138,33.555C41.464,34.127 41.646,34.765 41.646,35.377ZM28.013,30.208C27.336,30.208 26.959,29.817 26.946,29.127L26.776,22.044C26.763,21.354 27.271,20.859 28,20.859C28.703,20.859 29.25,21.367 29.237,22.057L29.042,29.127C29.029,29.831 28.651,30.208 28.013,30.208ZM28.013,34.57C27.232,34.57 26.555,33.945 26.555,33.177C26.555,32.396 27.219,31.771 28.013,31.771C28.795,31.771 29.459,32.383 29.459,33.177C29.459,33.958 28.782,34.57 28.013,34.57Z"
android:fillColor="#FFB71B"
android:fillType="evenOdd"/>
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.608,1.147C14.314,1.448 14.797,2.163 14.797,3.017L14.798,9.785C14.798,9.895 14.887,9.985 14.998,9.985H18.099C18.985,9.985 19.595,10.583 19.847,11.21C20.097,11.837 20.064,12.642 19.563,13.285L12.564,22.268C12.003,22.988 11.12,23.163 10.392,22.852C9.685,22.551 9.202,21.837 9.202,20.983L9.202,14.215C9.202,14.104 9.112,14.015 9.002,14.015H5.9C5.014,14.015 4.404,13.417 4.153,12.789C3.902,12.163 3.936,11.358 4.437,10.715L11.435,1.732C11.996,1.011 12.879,0.837 13.608,1.147Z"
android:fillColor="#0099FF"/>
</vector>

View file

@ -30,6 +30,7 @@ dependencies {
// region Project - Libs
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
implementation(tangemDeps.card.core)
// endregion
// region DI

View file

@ -1,5 +1,6 @@
package com.tangem.data.dynamicaddresses
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
@ -10,6 +11,7 @@ import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
@ -31,17 +33,20 @@ internal class DefaultDynamicAddressesRepository(
.map { response ->
val token = response.findToken(network)
when {
token?.dynamicAddressesEnabled == true -> DynamicAddressesStatus.ENABLED
else -> DynamicAddressesStatus.DISABLED
token?.dynamicAddressesEnabled != true -> DynamicAddressesStatus.DISABLED
!isXpubAvailable(userWalletId, network) -> DynamicAddressesStatus.ENABLED_REQUIRES_SETUP
else -> DynamicAddressesStatus.ENABLED
}
// TODO handle ENABLED_REQUIRES_SETUP when XPUB is not derived locally
}
.flowOn(dispatchers.io)
}
override suspend fun enable(userWalletId: UserWalletId, network: Network, xpub: String) {
withContext(dispatchers.io) {
walletManagersFacade.enableXpubMode(userWalletId, network, xpub)
val result = walletManagersFacade.enableXpubMode(userWalletId, network, xpub)
if (result is SimpleResult.Failure) {
error("Failed to enable xpub mode for $userWalletId / ${network.id}: ${result.error}")
}
updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { TangemLogger.e("Failed to sync tokens after DA enable for $userWalletId", it) }
@ -50,7 +55,10 @@ internal class DefaultDynamicAddressesRepository(
override suspend fun disable(userWalletId: UserWalletId, network: Network) {
withContext(dispatchers.io) {
walletManagersFacade.disableXpubMode(userWalletId, network)
val result = walletManagersFacade.disableXpubMode(userWalletId, network)
if (result is SimpleResult.Failure) {
error("Failed to disable xpub mode for $userWalletId / ${network.id}: ${result.error}")
}
updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { TangemLogger.e("Failed to sync tokens after DA disable for $userWalletId", it) }
@ -70,6 +78,45 @@ internal class DefaultDynamicAddressesRepository(
return walletManagersFacade.hasDynamicAddressesNonBaseBalances(userWalletId, network)
}
override suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean {
return withContext(dispatchers.io) {
val response = walletAccountsFetcher.getSaved(userWalletId) ?: return@withContext false
val baseDerivationPath = network.derivationPath.value ?: return@withContext false
response.accounts
.flatMap { it.tokens.orEmpty() }
.any { token ->
val tokenDerivationPath = token.derivationPath ?: return@any false
token.networkId == network.backendId &&
tokenDerivationPath != baseDerivationPath &&
hasNonZeroChangeOrIndex(tokenDerivationPath, baseDerivationPath)
}
}
}
/**
* Checks if the token's derivation path has the same first 3 nodes (purpose/coin/account)
* as the base path but different change/index nodes (not both 0).
*/
private fun hasNonZeroChangeOrIndex(tokenPath: String, basePath: String): Boolean {
val tokenNodes = runCatching { DerivationPath(tokenPath).nodes }.getOrNull() ?: return false
val baseNodes = runCatching { DerivationPath(basePath).nodes }.getOrNull() ?: return false
if (tokenNodes.size < DERIVATION_NODE_COUNT || baseNodes.size < DERIVATION_NODE_COUNT) return false
// First 3 nodes must match (purpose/coin/account) by value, ignoring hardening
val isSameAccount = (0 until ACCOUNT_NODE_COUNT).all { i ->
tokenNodes[i].getIndex(includeHardened = false) == baseNodes[i].getIndex(includeHardened = false)
}
if (!isSameAccount) return false
// Check if change or index ≠ 0
val change = tokenNodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false)
val index = tokenNodes[INDEX_NODE_INDEX].getIndex(includeHardened = false)
return change != 0L || index != 0L
}
private suspend fun updateTokenDynamicAddressesFlag(
userWalletId: UserWalletId,
network: Network,
@ -92,6 +139,11 @@ internal class DefaultDynamicAddressesRepository(
}
}
private suspend fun isXpubAvailable(userWalletId: UserWalletId, network: Network): Boolean {
// Check if WalletManager is already in XPUB mode (DA was previously enabled on this device)
return walletManagersFacade.getDynamicAddressesReceiveAddress(userWalletId, network) != null
}
private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? {
return accounts
.flatMap { it.tokens.orEmpty() }
@ -103,4 +155,11 @@ internal class DefaultDynamicAddressesRepository(
derivationPath == network.derivationPath.value &&
contractAddress == null
}
private companion object {
const val DERIVATION_NODE_COUNT = 5
const val ACCOUNT_NODE_COUNT = 3
const val CHANGE_NODE_INDEX = 3
const val INDEX_NODE_INDEX = 4
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.wallets.derivations
import arrow.core.getOrElse
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.extensions.toMapKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
@ -76,6 +77,23 @@ internal class DefaultDerivationsRepository @Inject constructor(
}
}
override suspend fun getExistingDerivedKeys(
userWalletId: UserWalletId,
seedKey: ByteArrayKey,
): ExtendedPublicKeysMap {
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
return userWallet.getExistingDerivedKeys()[seedKey] ?: ExtendedPublicKeysMap(emptyMap())
}
private fun UserWallet.getExistingDerivedKeys(): Map<ByteArrayKey, ExtendedPublicKeysMap> {
return when (this) {
is UserWallet.Cold -> scanResponse.derivedKeys
is UserWallet.Hot -> wallets
?.associate { it.publicKey.toMapKey() to ExtendedPublicKeysMap(it.derivedKeys) }
.orEmpty()
}
}
override suspend fun hasMissedDerivations(
userWalletId: UserWalletId,
networksWithDerivationPath: Map<BackendId, String?>,

View file

@ -13,8 +13,12 @@ dependencies {
api(projects.domain.dynamicAddresses.models)
implementation(projects.domain.models)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets)
implementation(projects.libs.blockchainSdk)
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
implementation(tangemDeps.card.core)
}

View file

@ -0,0 +1,53 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.blockchain.common.Blockchain
/**
* List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode).
* Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred).
*
* Per ASMPT-005: DA is NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses.
* Only the default derivation style per blockchain is supported.
*/
object DynamicAddressesSupportedBlockchains {
private const val BIP44_PURPOSE = 44L
private const val BIP84_PURPOSE = 84L
private val supported = setOf(
Blockchain.Bitcoin,
Blockchain.BitcoinTestnet,
Blockchain.BitcoinCash,
Blockchain.BitcoinCashTestnet,
Blockchain.Litecoin,
Blockchain.Dogecoin,
Blockchain.Dash,
Blockchain.Ravencoin,
Blockchain.RavencoinTestnet,
)
private val supportedNetworkIds = supported.map { it.id }.toSet()
/**
* Allowed BIP purpose nodes per network ID.
* BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH).
*/
private val allowedPurposeByNetworkId: Map<String, Long> = buildMap {
put(Blockchain.Bitcoin.id, BIP84_PURPOSE)
put(Blockchain.BitcoinTestnet.id, BIP84_PURPOSE)
put(Blockchain.Litecoin.id, BIP84_PURPOSE)
put(Blockchain.BitcoinCash.id, BIP44_PURPOSE)
put(Blockchain.BitcoinCashTestnet.id, BIP44_PURPOSE)
put(Blockchain.Dogecoin.id, BIP44_PURPOSE)
put(Blockchain.Dash.id, BIP44_PURPOSE)
put(Blockchain.Ravencoin.id, BIP44_PURPOSE)
put(Blockchain.RavencoinTestnet.id, BIP44_PURPOSE)
}
fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported
fun isSupportedByNetworkId(networkId: String): Boolean = networkId in supportedNetworkIds
/** Returns the allowed BIP purpose node for the given network, or null if not supported */
fun getAllowedPurpose(networkId: String): Long? = allowedPurposeByNetworkId[networkId]
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.dynamicaddresses
sealed class EnableDynamicAddressesError {
data object ConflictingCustomTokens : EnableDynamicAddressesError()
data class ServiceError(val cause: Throwable) : EnableDynamicAddressesError()
}

View file

@ -1,6 +1,8 @@
package com.tangem.domain.dynamicaddresses
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
@ -9,8 +11,19 @@ class EnableDynamicAddressesUseCase(
private val dynamicAddressesRepository: DynamicAddressesRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network, xpub: String): Either<Throwable, Unit> =
Either.catch {
suspend operator fun invoke(
userWalletId: UserWalletId,
network: Network,
xpub: String,
): Either<EnableDynamicAddressesError, Unit> {
return try {
if (dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)) {
return EnableDynamicAddressesError.ConflictingCustomTokens.left()
}
dynamicAddressesRepository.enable(userWalletId, network, xpub)
Unit.right()
} catch (e: Throwable) {
EnableDynamicAddressesError.ServiceError(e).left()
}
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.DerivationsRepository
/**
* Checks if the account-level XPUB key is already derived (no card scan needed).
*/
class IsXpubDerivedUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val derivationsRepository: DerivationsRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean {
val blockchain = network.toBlockchain()
if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false
if (!blockchain.isBip44DerivationStyleXPUB()) return false
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false
val hdKey = walletManager.wallet.publicKey.derivationType?.hdKey ?: return false
if (hdKey.path.nodes.size <= ACCOUNT_PATH_DROP_COUNT) return false
val accountPath = DerivationPath(hdKey.path.nodes.dropLast(ACCOUNT_PATH_DROP_COUNT))
val seedKey = ByteArrayKey(walletManager.wallet.publicKey.seedKey)
val existingKeys = derivationsRepository.getExistingDerivedKeys(userWalletId, seedKey)
return existingKeys[accountPath] != null
}
private companion object {
const val ACCOUNT_PATH_DROP_COUNT = 2
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
/**
* Checks if XPUB generation is supported for the given wallet and network (hardware capability check).
*/
class IsXpubSupportedUseCase(
private val walletManagersFacade: WalletManagersFacade,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean {
val blockchain = network.toBlockchain()
if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false
return walletManager.wallet.publicKey.derivationType?.hdKey != null
}
}

View file

@ -19,4 +19,7 @@ interface DynamicAddressesRepository {
suspend fun getLastUsedReceiveAddress(userWalletId: UserWalletId, network: Network): String?
suspend fun hasNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean
/** Returns true if there are custom tokens with change/index ≠ 0 that conflict with DA */
suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean
}

View file

@ -29,6 +29,9 @@ interface DerivationsRepository {
derivations: Map<ByteArrayKey, List<DerivationPath>>,
): Map<ByteArrayKey, ExtendedPublicKeysMap>
/** Returns already derived extended public keys for the given [seedKey] */
suspend fun getExistingDerivedKeys(userWalletId: UserWalletId, seedKey: ByteArrayKey): ExtendedPublicKeysMap
/** Check if user [userWalletId] has missed derivations using map of [Network.ID] with extraDerivationPath */
suspend fun hasMissedDerivations(
userWalletId: UserWalletId,

View file

@ -1,7 +1,6 @@
package com.tangem.domain.wallets.usecase
import arrow.core.Either
import arrow.core.right
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.extensions.ByteArrayKey
@ -38,23 +37,37 @@ class GetExtendedPublicKeyForCurrencyUseCase(
error("No derivation found")
}
val seedKey = walletManager.wallet.publicKey.seedKey
val existingKeys = derivationsRepository.getExistingDerivedKeys(
userWalletId = userWalletId,
seedKey = ByteArrayKey(seedKey),
)
var childKey = makeChildKey(
isBip44DerivationStyleXPUB = blockchain.isBip44DerivationStyleXPUB(),
extendedPublicKey = hdKey.extendedPublicKey,
derivationPath = hdKey.path,
)
// Fill from already derived keys if available
if (childKey.extendedPublicKey == null) {
existingKeys[childKey.derivationPath]?.let {
childKey = childKey.copy(extendedPublicKey = it)
}
}
val parentPath = childKey.derivationPath.dropLastNodes(1)
var parentKey = Key(
derivationPath = childKey.derivationPath.dropLastNodes(1),
extendedPublicKey = null,
derivationPath = parentPath,
extendedPublicKey = existingKeys[parentPath],
)
val pendingDerivations = getPendingDerivations(childKey, parentKey)
val derivedKeys = deriveKeys(
userWalletId = userWalletId,
seedKey = walletManager.wallet.publicKey.seedKey,
paths = pendingDerivations,
)
val derivedKeys = if (pendingDerivations.isNotEmpty()) {
deriveKeys(userWalletId = userWalletId, seedKey = seedKey, paths = pendingDerivations)
} else {
ExtendedPublicKeysMap(emptyMap())
}
if (childKey.extendedPublicKey == null) {
childKey = childKey.copy(
@ -72,22 +85,6 @@ class GetExtendedPublicKeyForCurrencyUseCase(
}
}
/**
* @return true if xpub generation is supported, false otherwise
*/
suspend fun isSupported(userWalletId: UserWalletId, network: Network): Either<Throwable, Boolean> = Either.catch {
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
?: error("Wallet not found for user wallet $userWalletId and network ${network.id}")
val blockchain = network.toBlockchain()
val isSecp256k1Blockchain = Blockchain.secp256k1Blockchains(network.isTestnet).contains(blockchain)
val isHdKey = walletManager.wallet.publicKey.derivationType?.hdKey
val isSupported = isSecp256k1Blockchain && isHdKey != null
return isSupported.right()
}
private suspend fun deriveKeys(
userWalletId: UserWalletId,
seedKey: ByteArray,

View file

@ -68,6 +68,8 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.card)
implementation(projects.domain.demo)
implementation(projects.domain.dynamicAddresses)
implementation(projects.domain.dynamicAddresses.models)
implementation(projects.domain.markets.models)
implementation(projects.domain.models)
implementation(projects.domain.notifications.models)

View file

@ -24,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetails
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreenLegacy
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.tokenreceive.TokenReceiveComponent
@ -148,6 +149,10 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
cloreMigrationModel = model.cloreMigrationModel,
onDismiss = model.bottomSheetNavigation::dismiss,
)
is TokenDetailsBottomSheetConfig.DynamicAddresses -> DynamicAddressesBottomSheetComponent(
dynamicAddressesDelegate = model.dynamicAddressesDelegate,
onDismiss = model.bottomSheetNavigation::dismiss,
)
}
@AssistedFactory

View file

@ -0,0 +1,125 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.model
import com.tangem.common.core.TangemSdkError
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class DynamicAddressesDelegate(
private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase,
private val isXpubDerivedUseCase: IsXpubDerivedUseCase,
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase,
private val uiMessageSender: UiMessageSender,
private val userWalletId: UserWalletId,
private val network: Network,
private val coroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider,
private val showBottomSheet: () -> Unit,
private val dismissBottomSheet: () -> Unit,
private val onDynamicAddressesEnabled: () -> Unit,
) {
private val _bottomSheetConfig = MutableStateFlow<DynamicAddressesBottomSheetConfig>(
DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = false,
onEnableClick = {},
),
)
val bottomSheetConfig: StateFlow<DynamicAddressesBottomSheetConfig> = _bottomSheetConfig.asStateFlow()
fun onDynamicAddressesClick() {
coroutineScope.launch(dispatchers.main) {
val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)
if (hasConflicts) {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Unavailable(
onGotItClick = dismissBottomSheet,
)
showBottomSheet()
return@launch
}
val isCardScanRequired = !isXpubAlreadyDerived()
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = isCardScanRequired,
onEnableClick = ::onEnableClick,
)
showBottomSheet()
}
}
private fun onEnableClick() {
coroutineScope.launch(dispatchers.main) {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = false,
isLoading = true,
onEnableClick = {},
)
val xpub = getExtendedPublicKeyUseCase(userWalletId, network).fold(
ifLeft = { error ->
if (isUserCancellation(error)) {
dismissBottomSheet()
} else {
TangemLogger.e("Failed to get XPUB: ${error.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onGotItClick = dismissBottomSheet,
)
}
return@launch
},
ifRight = { it },
)
enableDynamicAddressesUseCase(userWalletId, network, xpub).fold(
ifLeft = { error ->
when (error) {
is EnableDynamicAddressesError.ConflictingCustomTokens -> {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Unavailable(
onGotItClick = dismissBottomSheet,
)
}
is EnableDynamicAddressesError.ServiceError -> {
TangemLogger.e("Failed to enable DA: ${error.cause.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onGotItClick = dismissBottomSheet,
)
}
}
},
ifRight = {
dismissBottomSheet()
onDynamicAddressesEnabled()
uiMessageSender.send(
SnackbarMessage(message = resourceReference(R.string.dynamic_addresses_enabled_toast_title)),
)
},
)
}
}
private suspend fun isXpubAlreadyDerived(): Boolean {
return isXpubDerivedUseCase(userWalletId, network)
}
private fun isUserCancellation(error: Throwable): Boolean {
return error is TangemSdkError.UserCancelled || error.cause is TangemSdkError.UserCancelled
}
}

View file

@ -48,6 +48,8 @@ interface TokenDetailsClickIntents {
fun onGenerateExtendedKey()
fun onDynamicAddressesClick()
fun onCopyAddress(): TextReference?
fun onAssociateClick()
@ -125,6 +127,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents {
override fun onGenerateExtendedKey() { /* no op */ }
override fun onDynamicAddressesClick() { /* no op */ }
override fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }
override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }

View file

@ -2,13 +2,19 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import arrow.core.merge
import arrow.core.right
import com.tangem.utils.logging.TangemLogger
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.address.AddressType
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.receive.AddressModel
@ -16,9 +22,7 @@ import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
@ -53,6 +57,7 @@ import com.tangem.domain.models.TokenReceiveNotification
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -154,7 +159,6 @@ internal class TokenDetailsModel @Inject constructor(
private val appRouter: AppRouter,
private val router: InnerTokenDetailsRouter,
private val tokenDetailsDeepLinkActionListener: TokenDetailsDeepLinkActionListener,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val receiveAddressesFactory: ReceiveAddressesFactory,
private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase,
private val saveViewedTokenReceiveWarningUseCase: SaveViewedTokenReceiveWarningUseCase,
@ -163,6 +167,11 @@ internal class TokenDetailsModel @Inject constructor(
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val yieldSupplyGetRewardsBalanceUseCase: YieldSupplyGetRewardsBalanceUseCase,
private val signCloreMessageUseCase: SignCloreMessageUseCase,
private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase,
private val isXpubSupportedUseCase: IsXpubSupportedUseCase,
private val isXpubDerivedUseCase: IsXpubDerivedUseCase,
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
) : Model(),
TokenDetailsClickIntents,
ExpressTransactionsClickIntents,
@ -226,6 +235,27 @@ internal class TokenDetailsModel @Inject constructor(
}
// endregion
// region Dynamic Addresses
val dynamicAddressesDelegate by lazy(mode = LazyThreadSafetyMode.NONE) {
DynamicAddressesDelegate(
enableDynamicAddressesUseCase = enableDynamicAddressesUseCase,
isXpubDerivedUseCase = isXpubDerivedUseCase,
dynamicAddressesRepository = dynamicAddressesRepository,
getExtendedPublicKeyUseCase = getExtendedPublicKeyForCurrencyUseCase,
uiMessageSender = uiMessageSender,
userWalletId = userWalletId,
network = cryptoCurrency.network,
coroutineScope = modelScope,
dispatchers = dispatchers,
showBottomSheet = {
bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.DynamicAddresses)
},
dismissBottomSheet = bottomSheetNavigation::dismiss,
onDynamicAddressesEnabled = ::onDynamicAddressesEnabled,
)
}
// endregion Dynamic Addresses
private val expressStatusFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
tokenDetailsExpressStatusFactory.create(
clickIntents = this,
@ -485,39 +515,45 @@ internal class TokenDetailsModel @Inject constructor(
).getOrElse { false }
val isSupported = isXPUBSupported()
val isDynamicAddressesAvailable = isSupported && isDynamicAddressesAvailable()
internalUiState.value = stateFactory.getStateWithUpdatedMenu(
userWallet = userWallet,
hasDerivations = hasDerivations,
isSupported = isSupported,
isDynamicAddressesAvailable = isDynamicAddressesAvailable,
)
}
}
private fun isDynamicAddressesAvailable(): Boolean {
if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return false
if (cryptoCurrency !is CryptoCurrency.Coin) return false
val networkId = cryptoCurrency.network.rawId
if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(networkId)) return false
return isDefaultBaseDerivation(cryptoCurrency.network.derivationPath, networkId)
}
private fun isDefaultBaseDerivation(derivationPath: Network.DerivationPath, networkId: String): Boolean {
val pathValue = derivationPath.value ?: return false
val nodes = runCatching { DerivationPath(pathValue).nodes }.getOrNull() ?: return false
if (nodes.size < BASE_DERIVATION_NODE_COUNT) return false
val purposeNode = nodes.first()
val allowedPurpose = DynamicAddressesSupportedBlockchains.getAllowedPurpose(networkId) ?: return false
if (purposeNode.getIndex(includeHardened = false) != allowedPurpose) return false
val changeNode = nodes[nodes.size - 2]
val indexNode = nodes.last()
return changeNode.getIndex(includeHardened = false) == 0L &&
indexNode.getIndex(includeHardened = false) == 0L
}
private suspend fun isXPUBSupported(): Boolean {
return getExtendedPublicKeyForCurrencyUseCase.isSupported(
userWalletId = userWalletId,
network = cryptoCurrency.network,
)
.mapLeft { throwable ->
analyticsExceptionHandler.sendException(
event = ExceptionAnalyticsEvent(
exception = throwable,
params = mapOf(
"blockchainId" to cryptoCurrency.network.id.rawId.value,
"networkId" to cryptoCurrency.network.backendId,
),
),
)
TangemLogger.e(
"Unable to get wallet manager for user wallet $userWalletId and network ${cryptoCurrency.network}",
throwable,
)
false
}
.merge()
return isXpubSupportedUseCase(userWalletId = userWalletId, network = cryptoCurrency.network)
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
@ -657,6 +693,15 @@ internal class TokenDetailsModel @Inject constructor(
openStaking()
}
override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick()
private fun onDynamicAddressesEnabled() {
updateTopBarMenu()
modelScope.launch(dispatchers.main) {
cryptoCurrencyBalanceFetcher.invokeAndAwait(userWalletId = userWalletId, currency = cryptoCurrency)
}
}
override fun onGenerateExtendedKey() {
modelScope.launch(dispatchers.main) {
val extendedKey = getExtendedPublicKeyForCurrencyUseCase(
@ -1382,5 +1427,6 @@ internal class TokenDetailsModel @Inject constructor(
private companion object {
const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L
const val BASE_DERIVATION_NODE_COUNT = 5
}
}

View file

@ -27,4 +27,7 @@ sealed class TokenDetailsBottomSheetConfig : Route {
@Serializable
data object CloreMigration : TokenDetailsBottomSheetConfig()
@Serializable
data object DynamicAddresses : TokenDetailsBottomSheetConfig()
}

View file

@ -167,12 +167,18 @@ internal class TokenDetailsStateFactory(
userWallet: UserWallet,
hasDerivations: Boolean,
isSupported: Boolean,
isDynamicAddressesAvailable: Boolean = false,
): TokenDetailsState {
return with(currentStateProvider()) {
copy(
topAppBarConfig = topAppBarConfig.copy(
tokenDetailsAppBarMenuConfig = topAppBarConfig.tokenDetailsAppBarMenuConfig
?.updateMenu(userWallet, hasDerivations, isSupported),
?.updateMenu(
userWallet = userWallet,
hasDerivations = hasDerivations,
isSupported = isSupported,
isDynamicAddressesAvailable = isDynamicAddressesAvailable,
),
),
)
}
@ -206,6 +212,7 @@ internal class TokenDetailsStateFactory(
userWallet: UserWallet,
hasDerivations: Boolean,
isSupported: Boolean,
isDynamicAddressesAvailable: Boolean,
): TokenDetailsAppBarMenuConfig? {
if (userWallet is UserWallet.Cold &&
userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()
@ -215,6 +222,13 @@ internal class TokenDetailsStateFactory(
return copy(
items = buildList {
if (isDynamicAddressesAvailable) {
TangemDropdownMenuItem(
title = resourceReference(R.string.dynamic_addresses),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = tokenDetailsClickIntents::onDynamicAddressesClick,
).let(::add)
}
if (isSupported && hasDerivations) {
TangemDropdownMenuItem(
title = resourceReference(R.string.token_details_generate_xpub),

View file

@ -0,0 +1,34 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.model.DynamicAddressesDelegate
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheet
internal class DynamicAddressesBottomSheetComponent(
private val dynamicAddressesDelegate: DynamicAddressesDelegate,
private val onDismiss: () -> Unit,
) : ComposableBottomSheetComponent {
override fun dismiss() {
onDismiss()
}
@Composable
override fun BottomSheet() {
val content by dynamicAddressesDelegate.bottomSheetConfig.collectAsStateWithLifecycle()
val config = remember(content) {
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = content,
)
}
DynamicAddressesBottomSheet(config = config)
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses
import androidx.compose.runtime.Composable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.R as CoreR
@Composable
internal fun DynamicAddressesBottomSheet(config: TangemBottomSheetConfig) {
TangemModalBottomSheet<DynamicAddressesBottomSheetConfig>(
config = config,
title = {
TangemModalBottomSheetTitle(
endIconRes = CoreR.drawable.ic_close_24,
onEndClick = config.onDismissRequest,
)
},
) { content ->
when (content) {
is DynamicAddressesBottomSheetConfig.Enable -> DynamicAddressesEnableContent(content = content)
is DynamicAddressesBottomSheetConfig.Unavailable -> DynamicAddressesUnavailableContent(content = content)
is DynamicAddressesBottomSheetConfig.ServiceUnavailable -> DynamicAddressesServiceUnavailableContent(
content = content,
)
}
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
@Immutable
internal sealed class DynamicAddressesBottomSheetConfig : TangemBottomSheetConfigContent {
data class Enable(
val isCardScanRequired: Boolean,
val isLoading: Boolean = false,
val onEnableClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
data class Unavailable(
val onGotItClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
data class ServiceUnavailable(
val onGotItClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
}

View file

@ -0,0 +1,179 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.res.R
import com.tangem.core.ui.R as CoreR
@Composable
internal fun DynamicAddressesEnableContent(content: DynamicAddressesBottomSheetConfig.Enable) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_top),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size44),
tint = TangemTheme.colors.icon.accent,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12))
Text(
text = stringResourceSafe(id = R.string.dynamic_addresses),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
Text(
text = stringResourceSafe(id = R.string.dynamic_addresses_enter_subtitle),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
FeatureItem(
iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_flash_24,
title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_title),
description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_description),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
FeatureItem(
iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_check_24,
title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_title),
description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_description),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
PrimaryButton(
text = stringResourceSafe(id = R.string.dynamic_addresses_enter_main_button_title),
onClick = content.onEnableClick,
modifier = Modifier.fillMaxWidth(),
showProgress = content.isLoading,
enabled = !content.isLoading,
// TODO add card icon when isCardScanRequired
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
}
}
@Composable
internal fun DynamicAddressesUnavailableContent(content: DynamicAddressesBottomSheetConfig.Unavailable) {
ErrorContent(
titleRes = R.string.dynamic_addresses_error_has_custom_token_title,
descriptionRes = R.string.dynamic_addresses_error_has_custom_token_description,
buttonTextRes = R.string.common_got_it,
onButtonClick = content.onGotItClick,
)
}
@Composable
internal fun DynamicAddressesServiceUnavailableContent(content: DynamicAddressesBottomSheetConfig.ServiceUnavailable) {
ErrorContent(
titleRes = R.string.dynamic_addresses_error_service_unavailable_title,
descriptionRes = R.string.dynamic_addresses_error_service_unavailable_description,
buttonTextRes = R.string.common_got_it,
onButtonClick = content.onGotItClick,
)
}
@Composable
private fun ErrorContent(titleRes: Int, descriptionRes: Int, buttonTextRes: Int, onButtonClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_unavailable),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size44),
tint = TangemTheme.colors.icon.warning,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12))
Text(
text = stringResourceSafe(id = titleRes),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
Text(
text = stringResourceSafe(id = descriptionRes),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
PrimaryButton(
text = stringResourceSafe(id = buttonTextRes),
onClick = onButtonClick,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
}
}
@Composable
private fun FeatureItem(iconRes: Int, title: String, description: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size24),
tint = TangemTheme.colors.icon.accent,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing4))
Text(
text = description,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}