Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-16 10:57:01 +03:00
commit 3c2a006fb9
331 changed files with 8261 additions and 1670 deletions

View file

@ -109,6 +109,7 @@ dependencies {
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)
implementation(projects.domain.networks)
implementation(projects.domain.quotes)
implementation(projects.common)
implementation(projects.common.routing)
@ -152,6 +153,7 @@ dependencies {
implementation(projects.data.nft)
implementation(projects.data.onramp)
implementation(projects.data.networks)
implementation(projects.data.quotes)
/** Features */
implementation(projects.features.onboarding)

View file

@ -1,6 +1,9 @@
package com.tangem.tap
import android.app.Application
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import coil.ImageLoader
@ -71,9 +74,7 @@ import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.wallet.BuildConfig
import dagger.hilt.EntryPoints
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import org.rekotlin.Store
import kotlin.collections.set
import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository
@ -228,12 +229,32 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
// endregion
private val appScope = MainScope()
override fun onCreate() {
enableStrictModeInDebug()
super.onCreate()
init()
}
updateLogFiles()
private fun enableStrictModeInDebug() {
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
.detectLeakedClosableObjects()
.penaltyLog()
.build(),
)
}
}
private fun updateLogFiles() {
@ -260,18 +281,31 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
foregroundActivityObserver = ForegroundActivityObserver()
registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks)
// TODO: Try to performance and user experience.
// [REDACTED_JIRA]
// We need to initialize the toggles and excludedBlockchainsManager before the MainActivity starts using them.
runBlocking {
awaitAll(
async { featureTogglesManager.init() },
async { excludedBlockchainsManager.init() },
async { initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) },
async {
featureTogglesManager.init()
},
async {
excludedBlockchainsManager.init()
},
)
}
loadNativeLibraries()
appScope.launch {
initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize())
launch(Dispatchers.IO) {
loadNativeLibraries()
walletConnect2Repository.init(
projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId,
)
updateLogFiles()
}
}
ExceptionHandler.append(blockchainExceptionHandler)
if (LogConfig.network.blockchainSdkNetwork) {
BlockchainSdkRetrofitBuilder.interceptors = listOf(
createNetworkLoggingInterceptor(),
@ -287,9 +321,8 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
)
appStateHolder.mainStore = store
walletConnect2Repository.init(projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId)
appStateHolder.mainStore = store
}
private fun createReduxStore(): Store<AppState> {

View file

@ -20,18 +20,22 @@ internal class DefaultVisaAuthTokenStorage @Inject constructor(
private val dispatcherProvider: CoroutineDispatcherProvider,
) : VisaAuthTokenStorage {
private val secureStorage = AndroidSecureStorage(
preferences = SecureStorage.createEncryptedSharedPreferences(
context = applicationContext,
storageName = "visa_auth_storage",
),
)
private val secureStorage by lazy {
AndroidSecureStorage(
preferences = SecureStorage.createEncryptedSharedPreferences(
context = applicationContext,
storageName = "visa_auth_storage",
),
)
}
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
private val moshi by lazy {
Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
}
private val tokensAdapter = moshi.adapter(VisaAuthTokens::class.java)
private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) }
override suspend fun store(cardId: String, tokens: VisaAuthTokens) = withContext(dispatcherProvider.io) {
val json = tokensAdapter.toJson(tokens)

View file

@ -20,12 +20,14 @@ class DefaultVisaOTPStorage @Inject constructor(
private val dispatcherProvider: CoroutineDispatcherProvider,
) : VisaOTPStorage {
private val secureStorage = AndroidSecureStorage(
preferences = SecureStorage.createEncryptedSharedPreferences(
context = applicationContext,
storageName = "visa_otp_storage",
),
)
private val secureStorage by lazy {
AndroidSecureStorage(
preferences = SecureStorage.createEncryptedSharedPreferences(
context = applicationContext,
storageName = "visa_otp_storage",
),
)
}
override suspend fun saveOTP(cardId: String, data: VisaOtpData) = withContext(dispatcherProvider.io) {
secureStorage.store(data.rootOTP, VISA_ROOT_OTP_KEY_PREFIX + cardId)

View file

@ -5,6 +5,7 @@ import com.tangem.domain.managetokens.*
import com.tangem.domain.managetokens.repository.CustomTokensRepository
import com.tangem.domain.managetokens.repository.ManageTokensRepository
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.tokens.TokensFeatureToggles
import com.tangem.domain.tokens.repository.CurrenciesRepository
@ -73,6 +74,7 @@ internal object ManageTokensDomainModule {
stakingRepository: StakingRepository,
quotesRepository: QuotesRepository,
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteFetcher: MultiQuoteFetcher,
tokensFeatureToggles: TokensFeatureToggles,
): SaveManagedTokensUseCase {
return SaveManagedTokensUseCase(
@ -84,6 +86,7 @@ internal object ManageTokensDomainModule {
stakingRepository = stakingRepository,
quotesRepository = quotesRepository,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteFetcher = multiQuoteFetcher,
tokensFeatureToggles = tokensFeatureToggles,
)
}

View file

@ -5,6 +5,8 @@ import com.tangem.domain.card.repository.DerivationsRepository
import com.tangem.domain.markets.*
import com.tangem.domain.markets.repositories.MarketsTokenRepository
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.quotes.single.SingleQuoteSupplier
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.tokens.TokensFeatureToggles
import com.tangem.domain.tokens.repository.CurrenciesRepository
@ -49,8 +51,16 @@ object MarketsDomainModule {
@Provides
@Singleton
fun provideGetTokenQuotesUseCase(quotesRepository: QuotesRepository): GetCurrencyQuotesUseCase {
return GetCurrencyQuotesUseCase(quotesRepository = quotesRepository)
fun provideGetTokenQuotesUseCase(
quotesRepository: QuotesRepository,
singleQuoteSupplier: SingleQuoteSupplier,
tokensFeatureToggles: TokensFeatureToggles,
): GetCurrencyQuotesUseCase {
return GetCurrencyQuotesUseCase(
quotesRepository = quotesRepository,
singleQuoteSupplier = singleQuoteSupplier,
tokensFeatureToggles = tokensFeatureToggles,
)
}
@Provides
@ -63,6 +73,7 @@ object MarketsDomainModule {
stakingRepository: StakingRepository,
quotesRepository: QuotesRepository,
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteFetcher: MultiQuoteFetcher,
tokensFeatureToggles: TokensFeatureToggles,
): SaveMarketTokensUseCase {
return SaveMarketTokensUseCase(
@ -73,6 +84,7 @@ object MarketsDomainModule {
stakingRepository = stakingRepository,
quotesRepository = quotesRepository,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteFetcher = multiQuoteFetcher,
tokensFeatureToggles = tokensFeatureToggles,
)
}

View file

@ -7,6 +7,9 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.quotes.QuotesRepositoryV2
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.quotes.single.SingleQuoteSupplier
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.operations.BaseCurrenciesStatusesOperations
@ -35,6 +38,7 @@ internal object TokensDomainModule {
stakingRepository: StakingRepository,
quotesRepository: QuotesRepository,
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteFetcher: MultiQuoteFetcher,
tokensFeatureToggles: TokensFeatureToggles,
): AddCryptoCurrenciesUseCase {
return AddCryptoCurrenciesUseCase(
@ -43,6 +47,7 @@ internal object TokensDomainModule {
stakingRepository = stakingRepository,
quotesRepository = quotesRepository,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteFetcher = multiQuoteFetcher,
tokensFeatureToggles = tokensFeatureToggles,
)
}
@ -55,6 +60,7 @@ internal object TokensDomainModule {
networksRepository: NetworksRepository,
stakingRepository: StakingRepository,
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteFetcher: MultiQuoteFetcher,
tokensFeatureToggles: TokensFeatureToggles,
): FetchTokenListUseCase {
return FetchTokenListUseCase(
@ -63,6 +69,7 @@ internal object TokensDomainModule {
quotesRepository = quotesRepository,
stakingRepository = stakingRepository,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteFetcher = multiQuoteFetcher,
tokensFeatureToggles = tokensFeatureToggles,
)
}
@ -168,6 +175,7 @@ internal object TokensDomainModule {
networksRepository: NetworksRepository,
stakingRepository: StakingRepository,
singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
multiQuoteFetcher: MultiQuoteFetcher,
tokensFeatureToggles: TokensFeatureToggles,
): FetchCurrencyStatusUseCase {
return FetchCurrencyStatusUseCase(
@ -176,6 +184,7 @@ internal object TokensDomainModule {
quotesRepository = quotesRepository,
stakingRepository = stakingRepository,
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
multiQuoteFetcher = multiQuoteFetcher,
tokensFeatureToggles = tokensFeatureToggles,
)
}
@ -188,6 +197,7 @@ internal object TokensDomainModule {
networksRepository: NetworksRepository,
stakingRepository: StakingRepository,
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteFetcher: MultiQuoteFetcher,
tokensFeatureToggles: TokensFeatureToggles,
): FetchCardTokenListUseCase {
return FetchCardTokenListUseCase(
@ -196,6 +206,7 @@ internal object TokensDomainModule {
quotesRepository = quotesRepository,
stakingRepository = stakingRepository,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteFetcher = multiQuoteFetcher,
tokensFeatureToggles = tokensFeatureToggles,
)
}
@ -364,10 +375,14 @@ internal object TokensDomainModule {
fun provideRefreshMultiCurrencyWalletQuotesUseCase(
currenciesRepository: CurrenciesRepository,
quotesRepository: QuotesRepository,
multiQuoteFetcher: MultiQuoteFetcher,
tokensFeatureToggles: TokensFeatureToggles,
): RefreshMultiCurrencyWalletQuotesUseCase {
return RefreshMultiCurrencyWalletQuotesUseCase(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
multiQuoteFetcher = multiQuoteFetcher,
tokensFeatureToggles = tokensFeatureToggles,
)
}
@ -386,20 +401,26 @@ internal object TokensDomainModule {
tokensFeatureToggles: TokensFeatureToggles,
currenciesRepository: CurrenciesRepository,
quotesRepository: QuotesRepository,
quotesRepositoryV2: QuotesRepositoryV2,
networksRepository: NetworksRepository,
stakingRepository: StakingRepository,
singleNetworkStatusSupplier: SingleNetworkStatusSupplier,
multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteFetcher: MultiQuoteFetcher,
singleQuoteSupplier: SingleQuoteSupplier,
): BaseCurrenciesStatusesOperations {
return CachedCurrenciesStatusesOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
quotesRepositoryV2 = quotesRepositoryV2,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
singleNetworkStatusSupplier = singleNetworkStatusSupplier,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteFetcher = multiQuoteFetcher,
singleQuoteSupplier = singleQuoteSupplier,
tokensFeatureToggles = tokensFeatureToggles,
)
}
@ -410,21 +431,33 @@ internal object TokensDomainModule {
tokensFeatureToggles: TokensFeatureToggles,
currenciesRepository: CurrenciesRepository,
quotesRepository: QuotesRepository,
quotesRepositoryV2: QuotesRepositoryV2,
networksRepository: NetworksRepository,
stakingRepository: StakingRepository,
singleNetworkStatusSupplier: SingleNetworkStatusSupplier,
multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteFetcher: MultiQuoteFetcher,
singleQuoteSupplier: SingleQuoteSupplier,
): BaseCurrencyStatusOperations {
return CachedCurrenciesStatusesOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
quotesRepositoryV2 = quotesRepositoryV2,
networksRepository = networksRepository,
stakingRepository = stakingRepository,
singleNetworkStatusSupplier = singleNetworkStatusSupplier,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteFetcher = multiQuoteFetcher,
singleQuoteSupplier = singleQuoteSupplier,
tokensFeatureToggles = tokensFeatureToggles,
)
}
@Provides
@Singleton
fun provideGetCryptoCurrenciesUseCase(currenciesRepository: CurrenciesRepository): GetCryptoCurrenciesUseCase {
return GetCryptoCurrenciesUseCase(currenciesRepository)
}
}

View file

@ -147,4 +147,22 @@ internal object TransactionDomainModule {
fun provideGetAllowanceUseCase(transactionRepository: TransactionRepository): GetAllowanceUseCase {
return GetAllowanceUseCase(transactionRepository)
}
@Provides
@Singleton
fun providePrepareForSendUseCase(
transactionRepository: TransactionRepository,
cardSdkConfigRepository: CardSdkConfigRepository,
): PrepareForSendUseCase {
return PrepareForSendUseCase(transactionRepository, cardSdkConfigRepository)
}
@Provides
@Singleton
fun provideSignUseCase(
walletManagersFacade: WalletManagersFacade,
cardSdkConfigRepository: CardSdkConfigRepository,
): SignUseCase {
return SignUseCase(cardSdkConfigRepository, walletManagersFacade)
}
}

View file

@ -14,6 +14,8 @@ import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSy
import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.features.nft.NFTFeatureToggles
import com.tangem.operations.attestation.CardArtworksProvider
import com.tangem.sdk.api.featuretoggles.CardSdkFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -172,8 +174,16 @@ internal object WalletsDomainModule {
@Provides
@Singleton
fun provideGetCardImageUseCase(onlineCardVerifier: OnlineCardVerifier): GetCardImageUseCase {
return GetCardImageUseCase(verifier = onlineCardVerifier)
fun provideGetCardImageUseCase(
onlineCardVerifier: OnlineCardVerifier,
cardArtworksProvider: CardArtworksProvider,
cardSdkFeatureToggles: CardSdkFeatureToggles,
): GetCardImageUseCase {
return GetCardImageUseCase(
verifier = onlineCardVerifier,
cardArtworksProvider = cardArtworksProvider,
cardSdkFeatureToggles = cardSdkFeatureToggles,
)
}
@Provides

View file

@ -6,6 +6,7 @@ import com.tangem.domain.common.TwinCardNumber
import com.tangem.domain.common.getTwinCardNumber
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.wallets.models.Artwork
import com.tangem.operations.attestation.CardArtworksProvider
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.operations.attestation.api.models.CardVerifyAndGetInfo
@ -37,7 +38,7 @@ suspend fun CardDTO.getOrLoadCardArtworkUrl(
if (artworkId.isNullOrEmpty()) {
ifAnyError()
} else {
OnlineCardVerifier.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId)
CardArtworksProvider.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId)
}
}

View file

@ -9,4 +9,10 @@ internal class DefaultTokensFeatureToggles(
override val isNetworksLoadingRefactoringEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NETWORKS_LOADING_REFACTORING_ENABLED")
override val isQuotesLoadingRefactoringEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "QUOTES_LOADING_REFACTORING_ENABLED")
override val isStakingLoadingRefactoringEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "STAKING_LOADING_REFACTORING_ENABLED")
}

View file

@ -21,8 +21,6 @@ internal data class UserWalletPublicInformation(
val name: String,
@Json(name = "walletId")
val walletId: UserWalletId,
@Json(name = "artworkUrl")
val artworkUrl: String,
@Json(name = "cardsInWallet")
val cardsInWallet: Set<String>,
@Json(name = "scanResponse")

View file

@ -20,9 +20,12 @@ internal class DefaultUserWalletsPublicInformationRepository(
moshi: Moshi,
private val secureStorage: SecureStorage,
) : UserWalletsPublicInformationRepository {
private val publicInformationAdapter: JsonAdapter<List<UserWalletPublicInformation>> = moshi.adapter(
Types.newParameterizedType(List::class.java, UserWalletPublicInformation::class.java),
)
private val publicInformationAdapter: JsonAdapter<List<UserWalletPublicInformation>> by lazy {
moshi.adapter(
Types.newParameterizedType(List::class.java, UserWalletPublicInformation::class.java),
)
}
override suspend fun save(userWallet: UserWallet, canOverride: Boolean): CompletionResult<Unit> {
return withContext(Dispatchers.IO) {

View file

@ -24,12 +24,15 @@ internal class DefaultUserWalletsSensitiveInformationRepository(
private val secureStorage: SecureStorage,
) : UserWalletsSensitiveInformationRepository {
private val sensitiveInformationAdapter: JsonAdapter<UserWalletSensitiveInformation> = moshi.adapter(
UserWalletSensitiveInformation::class.java,
)
private val encryptedSensitiveInformationMapAdapter: JsonAdapter<Map<String, ByteArray>> = moshi.adapter(
Types.newParameterizedType(Map::class.java, String::class.java, ByteArray::class.java),
)
private val sensitiveInformationAdapter: JsonAdapter<UserWalletSensitiveInformation> by lazy {
moshi.adapter(UserWalletSensitiveInformation::class.java)
}
private val encryptedSensitiveInformationMapAdapter: JsonAdapter<Map<String, ByteArray>> by lazy {
moshi.adapter(
Types.newParameterizedType(Map::class.java, String::class.java, ByteArray::class.java),
)
}
override suspend fun save(userWallet: UserWallet, encryptionKey: ByteArray?): CompletionResult<Unit> {
if (encryptionKey == null) {

View file

@ -15,7 +15,6 @@ internal val UserWallet.publicInformation: UserWalletPublicInformation
get() = UserWalletPublicInformation(
name = name,
walletId = walletId,
artworkUrl = artworkUrl,
cardsInWallet = cardsInWallet,
isMultiCurrency = isMultiCurrency,
scanResponse = scanResponse.copy(
@ -31,7 +30,6 @@ internal fun UserWalletPublicInformation.toUserWallet(): UserWallet {
return UserWallet(
name = name,
walletId = walletId,
artworkUrl = artworkUrl,
cardsInWallet = cardsInWallet,
scanResponse = scanResponse,
isMultiCurrency = isMultiCurrency,

View file

@ -4,7 +4,6 @@ import com.tangem.Message
import com.tangem.blockchain.blockchains.ethereum.EthereumGasLoader
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils.toKeccak
import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.extensions.*
@ -16,6 +15,7 @@ import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.Basic.TransactionSent.MemoType
import com.tangem.data.walletconnect.network.ethereum.LegacySdkHelper
import com.tangem.operations.sign.SignHashCommand
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.extensions.safeUpdate
@ -357,36 +357,9 @@ class WalletConnectSdkHelper {
)
}
private fun createMessageData(message: WcSignMessage): ByteArray {
val messageData = try {
message.data.removePrefix(HEX_PREFIX).hexToBytes()
} catch (exception: Exception) {
message.data.asciiToHex()?.hexToBytes() ?: byteArrayOf()
}
private fun createMessageData(message: WcSignMessage): ByteArray = LegacySdkHelper.createMessageData(message.data)
val prefixData = (ETH_MESSAGE_PREFIX + messageData.size.toString()).toByteArray()
return (prefixData + messageData).toKeccak()
}
private fun String.hexToAscii(): String? {
return try {
removePrefix(HEX_PREFIX).hexToBytes()
.map {
val char = it.toInt().toChar()
if (char.isAscii()) char else return null
}
.joinToString("")
} catch (exception: Exception) {
return null
}
}
private fun String.asciiToHex(): String? {
return map {
if (!it.isAscii()) return null
Integer.toHexString(it.code)
}.joinToString("")
}
private fun String.hexToAscii(): String? = LegacySdkHelper.hexToAscii(hex = this)
suspend fun signPersonalMessage(
hashToSign: ByteArray,
@ -535,7 +508,6 @@ class WalletConnectSdkHelper {
"{\"signature\":\"$signature\",\"publicKey\":\"$publicKey\"}"
private companion object {
const val ETH_MESSAGE_PREFIX = "\u0019Ethereum Signed Message:\n"
const val HEX_PREFIX = "0x"
const val DEFAULT_MAX_GASLIMIT = 350000
// TODO remove after [REDACTED_JIRA]

View file

@ -31,7 +31,7 @@ import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.onboarding.data.model.CreateWalletResponse
import com.tangem.feature.onboarding.presentation.wallet2.analytics.SeedPhraseSource
import com.tangem.feature.wallet.presentation.wallet.domain.BackupValidator
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.operations.attestation.CardArtworksProvider
import com.tangem.operations.backup.BackupService
import com.tangem.sdk.api.CreateProductWalletTaskResponse
import com.tangem.sdk.extensions.localizedDescriptionRes
@ -250,7 +250,7 @@ private suspend fun loadArtworkForCard(cardId: String, cardPublicKey: ByteArray,
if (artworkId.isNullOrEmpty()) {
defaultArtwork ?: Uri.EMPTY
} else {
Uri.parse(OnlineCardVerifier.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId))
Uri.parse(CardArtworksProvider.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId))
}
}
is Result.Failure -> defaultArtwork ?: Uri.EMPTY

View file

@ -32,6 +32,7 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.features.onramp.OnrampFeatureToggles
import com.tangem.operations.attestation.CardArtworksProvider
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository
@ -76,5 +77,6 @@ data class DaggerGraphState(
val settingsManager: SettingsManager? = null,
val uiMessageSender: UiMessageSender? = null,
val onlineCardVerifier: OnlineCardVerifier? = null,
val cardArworksProvider: CardArtworksProvider? = null,
val userWalletBuilderFactory: UserWalletBuilder.Factory? = null,
) : StateType

View file

@ -12,13 +12,12 @@ import com.tangem.features.disclaimer.api.components.DisclaimerComponent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.nft.component.NFTCollectionsComponent
import com.tangem.features.nft.component.NFTDetailsComponent
import com.tangem.features.nft.component.NFTReceiveComponent
import com.tangem.features.nft.component.*
import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent
import com.tangem.features.onramp.component.*
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
import com.tangem.features.send.api.SendComponent
import com.tangem.features.send.v2.api.NFTSendComponent
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.staking.api.StakingComponent
import com.tangem.features.swap.SwapComponent
@ -83,9 +82,8 @@ internal class ChildFactory @Inject constructor(
private val sendComponentFactoryV2: com.tangem.features.send.v2.api.SendComponent.Factory,
private val sendFeatureToggles: SendFeatureToggles,
private val redesignedWalletConnectComponentFactory: RedisegnedWalletConnectComponent.Factory,
private val nftCollectionsComponentFactory: NFTCollectionsComponent.Factory,
private val nftReceiveComponentFactory: NFTReceiveComponent.Factory,
private val nftDetailsComponentFactory: NFTDetailsComponent.Factory,
private val nftComponentFactory: NFTComponent.Factory,
private val nftSendComponentFactory: NFTSendComponent.Factory,
private val testerRouter: TesterRouter,
private val routingFeatureToggles: RoutingFeatureToggles,
private val walletConnectFeatureToggles: WalletConnectFeatureToggles,
@ -405,24 +403,23 @@ internal class ChildFactory @Inject constructor(
componentFactory = walletComponentFactory,
)
}
is AppRoute.NFTCollections ->
is AppRoute.NFT ->
createComponentChild(
context = context,
params = NFTCollectionsComponent.Params(userWalletId = route.userWalletId),
componentFactory = nftCollectionsComponentFactory,
params = NFTComponent.Params(userWalletId = route.userWalletId),
componentFactory = nftComponentFactory,
)
is AppRoute.NFTReceive ->
is AppRoute.NFTSend -> {
createComponentChild(
context = context,
params = NFTReceiveComponent.Params(userWalletId = route.userWalletId),
componentFactory = nftReceiveComponentFactory,
)
is AppRoute.NFTDetails ->
createComponentChild(
context = context,
params = NFTDetailsComponent.Params(userWalletId = route.userWalletId, nftAsset = route.nftAsset),
componentFactory = nftDetailsComponentFactory,
params = NFTSendComponent.Params(
userWalletId = route.userWalletId,
nftAsset = route.nftAsset,
nftCollectionName = route.nftCollectionName,
),
componentFactory = nftSendComponentFactory,
)
}
is AppRoute.OnboardingNote,
is AppRoute.SaveWallet,
is AppRoute.OnboardingOther,
@ -757,24 +754,23 @@ internal class ChildFactory @Inject constructor(
componentFactory = storiesComponentFactory,
)
}
is AppRoute.NFTCollections ->
is AppRoute.NFT ->
route.asComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = NFTCollectionsComponent.Params(userWalletId = route.userWalletId),
componentFactory = nftCollectionsComponentFactory,
params = NFTComponent.Params(userWalletId = route.userWalletId),
componentFactory = nftComponentFactory,
)
is AppRoute.NFTReceive ->
is AppRoute.NFTSend -> {
route.asComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = NFTReceiveComponent.Params(userWalletId = route.userWalletId),
componentFactory = nftReceiveComponentFactory,
)
is AppRoute.NFTDetails ->
route.asComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = NFTDetailsComponent.Params(userWalletId = route.userWalletId, nftAsset = route.nftAsset),
componentFactory = nftDetailsComponentFactory,
params = NFTSendComponent.Params(
userWalletId = route.userWalletId,
nftAsset = route.nftAsset,
nftCollectionName = route.nftCollectionName,
),
componentFactory = nftSendComponentFactory,
)
}
}
// endregion
}

View file

@ -39,7 +39,6 @@ internal class DefaultDerivationsRepositoryTest {
private val defaultUserWallet = UserWallet(
name = "",
walletId = defaultUserWalletId,
artworkUrl = "",
cardsInWallet = setOf(),
isMultiCurrency = false,
scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()),

View file

@ -194,7 +194,6 @@ internal class BiometricUserWalletsListManagerTest(private val model: Model) {
return UserWallet(
name = "Wallet $id",
walletId = UserWalletId(stringValue = id),
artworkUrl = "",
cardsInWallet = emptySet(),
isMultiCurrency = true,
hasBackupError = false,

View file

@ -281,18 +281,14 @@ sealed class AppRoute(val path: String) : Route {
) : AppRoute(path = "/stories$storyId")
@Serializable
data class NFTCollections(
data class NFT(
val userWalletId: UserWalletId,
) : AppRoute(path = "/nft_collections/${userWalletId.stringValue}")
) : AppRoute(path = "/nft/${userWalletId.stringValue}")
@Serializable
data class NFTReceive(
val userWalletId: UserWalletId,
) : AppRoute(path = "/nft_receive/${userWalletId.stringValue}")
@Serializable
data class NFTDetails(
data class NFTSend(
val userWalletId: UserWalletId,
val nftAsset: NFTAsset,
) : AppRoute(path = "/nft_details/${userWalletId.stringValue}/${nftAsset.collectionId}/${nftAsset.id.stringValue}")
val nftCollectionName: String,
) : AppRoute(path = "/send/nft/${userWalletId.stringValue}/$nftCollectionName/${nftAsset.id}")
}

View file

@ -9,6 +9,9 @@ android {
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.data.common)
implementation(projects.domain.legacy)

View file

@ -0,0 +1,19 @@
package com.tangem.common.test.data.quote
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
object MockQuoteResponseFactory {
fun createSinglePrice(value: BigDecimal): QuotesResponse.Quote {
return QuotesResponse.Quote(
price = value,
priceChange24h = value,
priceChange1w = value,
priceChange30d = value,
)
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.common.test.data.quote
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.local.quote.converter.QuoteConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.Quote
fun QuotesResponse.Quote.toDomain(rawCurrencyId: String, source: StatusSource = StatusSource.ACTUAL): Quote {
return QuoteConverter(source = source).convert(value = mapOf(rawCurrencyId to this).entries.first())
}
fun Pair<String, QuotesResponse.Quote>.toDomain(source: StatusSource = StatusSource.ACTUAL): Quote {
return QuoteConverter(source = source).convert(value = mapOf(this).entries.first())
}

View file

@ -23,7 +23,6 @@ object MockUserWalletFactory {
return UserWallet(
walletId = userWalletId,
name = "Wallet 1",
artworkUrl = "",
cardsInWallet = emptySet(),
scanResponse = scanResponse,
isMultiCurrency = scanResponse.cardTypesResolver.isMultiwalletAllowed(),

View file

@ -1,19 +1,18 @@
package com.tangem.common.test.utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> CoroutineScope.getEmittedValues(testScheduler: TestCoroutineScheduler, actual: Flow<T>): List<T> {
fun <T> TestScope.getEmittedValues(flow: Flow<T>): List<T> {
val values = mutableListOf<T>()
launch(UnconfinedTestDispatcher(testScheduler)) {
actual.toList(values)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
flow.toList(values)
}
return values

View file

@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material3.CardColors
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -61,7 +61,7 @@ fun UserWalletItem(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
CardImage(imageUrl = state.imageUrl)
CardImage(state.imageState)
NameAndInfo(
modifier = Modifier.weight(1f),
name = state.name,
@ -147,39 +147,49 @@ private fun NameAndInfo(
}
@Composable
private fun CardImage(imageUrl: String, modifier: Modifier = Modifier) {
private fun CardImage(imageState: UserWalletItemUM.ImageState, modifier: Modifier = Modifier) {
val imageModifier = modifier
.width(TangemTheme.dimens.size24)
.height(TangemTheme.dimens.size36)
.clip(TangemTheme.shapes.roundedCornersSmall)
SubcomposeAsyncImage(
modifier = imageModifier,
model = ImageRequest.Builder(LocalContext.current)
.transformations(RotationTransformation(angle = 90f))
.size(
width = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() },
height = with(LocalDensity.current) { TangemTheme.dimens.size24.roundToPx() },
)
.data(imageUrl)
.crossfade(enable = true)
.allowHardware(enable = false)
.build(),
loading = {
when (imageState) {
is UserWalletItemUM.ImageState.Loading -> {
RectangleShimmer(
modifier = imageModifier,
radius = TangemTheme.dimens.size2,
)
},
error = {
Image(
}
is UserWalletItemUM.ImageState.Image -> {
SubcomposeAsyncImage(
modifier = imageModifier,
imageVector = ImageVector.vectorResource(R.drawable.img_card_wallet_2_gray_22_36),
model = ImageRequest.Builder(LocalContext.current)
.transformations(RotationTransformation(angle = 90f))
.size(
width = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() },
height = with(LocalDensity.current) { TangemTheme.dimens.size24.roundToPx() },
)
.data(imageState.artwork.verifiedArtwork?.toByteArray() ?: imageState.artwork.defaultUrl)
.crossfade(enable = true)
.allowHardware(enable = false)
.build(),
loading = {
RectangleShimmer(
modifier = imageModifier,
radius = TangemTheme.dimens.size2,
)
},
error = {
Image(
modifier = imageModifier,
imageVector = ImageVector.vectorResource(R.drawable.img_card_wallet_2_gray_22_36),
contentDescription = null,
)
},
contentDescription = null,
)
},
contentDescription = null,
)
}
}
}
@Composable
@ -215,7 +225,6 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider<UserWal
name = stringReference("My Wallet"),
information = getInformation(cardCount = 1),
balance = UserWalletItemUM.Balance.Locked,
imageUrl = "",
isEnabled = true,
onClick = {},
),
@ -224,7 +233,6 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider<UserWal
name = stringReference("Old wallet"),
information = getInformation(cardCount = 2),
balance = UserWalletItemUM.Balance.Hidden,
imageUrl = "",
isEnabled = true,
onClick = {},
endIcon = UserWalletItemUM.EndIcon.Arrow,
@ -234,7 +242,6 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider<UserWal
name = stringReference("Multi Card"),
information = getInformation(cardCount = 3),
balance = UserWalletItemUM.Balance.Failed,
imageUrl = "",
isEnabled = false,
endIcon = UserWalletItemUM.EndIcon.Checkmark,
onClick = {},
@ -244,7 +251,6 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider<UserWal
name = stringReference("Multi Card"),
information = getInformation(cardCount = 3),
balance = UserWalletItemUM.Balance.Loading,
imageUrl = "",
isEnabled = false,
endIcon = UserWalletItemUM.EndIcon.Checkmark,
onClick = {},
@ -257,7 +263,6 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider<UserWal
value = "1.2345 BTC",
isFlickering = false,
),
imageUrl = "",
isEnabled = false,
endIcon = UserWalletItemUM.EndIcon.Checkmark,
onClick = {},
@ -270,7 +275,6 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider<UserWal
value = "1.2345 BTC",
isFlickering = true,
),
imageUrl = "",
isEnabled = false,
endIcon = UserWalletItemUM.EndIcon.Checkmark,
onClick = {},

View file

@ -2,6 +2,7 @@ package com.tangem.common.ui.userwallet.converter
import com.tangem.common.ui.R
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.artwork.ArtworkUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
@ -9,6 +10,7 @@ import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.common.util.getCardsCount
import com.tangem.domain.models.ArtworkModel
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.TotalFiatBalance
import com.tangem.domain.wallets.models.UserWallet
@ -31,6 +33,7 @@ class UserWalletItemUMConverter(
private val balance: TotalFiatBalance? = null,
private val isBalanceHidden: Boolean = false,
private val endIcon: UserWalletItemUM.EndIcon = UserWalletItemUM.EndIcon.None,
private val artwork: ArtworkModel? = null,
) : Converter<UserWallet, UserWalletItemUM> {
override fun convert(value: UserWallet): UserWalletItemUM {
@ -40,10 +43,12 @@ class UserWalletItemUMConverter(
name = stringReference(name),
information = getInfo(userWallet = this),
balance = getBalanceInfo(userWallet = this),
imageUrl = artworkUrl,
isEnabled = !isLocked,
endIcon = endIcon,
onClick = { onClick(value.walletId) },
imageState = artwork?.let {
UserWalletItemUM.ImageState.Image(ArtworkUM(it.verifiedArtwork, it.defaultUrl))
} ?: UserWalletItemUM.ImageState.Loading,
)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.common.ui.userwallet.state
import com.tangem.core.ui.components.artwork.ArtworkUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.wallets.models.UserWalletId
import javax.annotation.concurrent.Immutable
@ -10,7 +11,7 @@ data class UserWalletItemUM(
val name: TextReference,
val information: TextReference,
val balance: Balance,
val imageUrl: String,
val imageState: ImageState = ImageState.Loading,
val isEnabled: Boolean,
val endIcon: EndIcon = EndIcon.None,
val onClick: () -> Unit,
@ -36,4 +37,14 @@ data class UserWalletItemUM(
val isFlickering: Boolean,
) : Balance()
}
@Immutable
sealed class ImageState {
data object Loading : ImageState()
data class Image(
val artwork: ArtworkUM,
) : ImageState()
}
}

View file

@ -22,9 +22,35 @@ sealed class MainScreenAnalyticsEvent(
)
// region Action Buttons feature
data class ButtonBuy(val status: AnalyticsParam.Status) : MainScreenAnalyticsEvent(
data class ButtonBuy(
val status: AnalyticsParam.Status,
val screenType: String? = null,
) : MainScreenAnalyticsEvent(
event = "Button - Buy",
params = mapOf(AnalyticsParam.STATUS to status.value),
params = buildMap {
put(AnalyticsParam.STATUS, status.value)
screenType?.let { put(AnalyticsParam.TYPE, it) }
},
)
data object ButtonReceive : MainScreenAnalyticsEvent(
event = "Button - Receive",
)
data object LimitsClicked : MainScreenAnalyticsEvent(
event = "Limits Clicked",
)
data object NoticeBalancesInfo : MainScreenAnalyticsEvent(
event = "Notice - Balances Info",
)
data object NoticeLimitsInfo : MainScreenAnalyticsEvent(
event = "Notice - Limits Info",
)
data object ButtonExplore : MainScreenAnalyticsEvent(
event = "Button - Explore",
)
data class ButtonSwap(val status: AnalyticsParam.Status) : MainScreenAnalyticsEvent(
@ -83,4 +109,9 @@ sealed class MainScreenAnalyticsEvent(
params = mapOf(ERROR_CODE to errorCode),
)
// endregion
companion object {
const val VISA_TYPE = "Visa"
const val WALLET_TYPE = "Wallet"
}
}

View file

@ -43,6 +43,10 @@
"name": "NOTE_REFACTORING_ENABLED",
"version": "5.23.0"
},
{
"name": "NEW_ARTWORK_LOADING",
"version": "5.24.0"
},
{
"name": "NEW_ATTESTATION_ENABLED",
"version": "5.23.0"
@ -62,5 +66,13 @@
{
"name": "NETWORKS_LOADING_REFACTORING_ENABLED",
"version": "5.23.0"
},
{
"name": "QUOTES_LOADING_REFACTORING_ENABLED",
"version": "5.24.0"
},
{
"name": "STAKING_LOADING_REFACTORING_ENABLED",
"version": "undefined"
}
]

View file

@ -32,7 +32,7 @@ internal class DevFeatureTogglesManager(
val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull<Map<String, Boolean>>(
key = PreferencesKeys.FEATURE_TOGGLES_KEY,
) ?: emptyMap()
) ?: emptyMap<String, Boolean>()
val localFeatureToggles = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get().orEmpty())

View file

@ -2,6 +2,7 @@ package com.tangem.core.configtoggle.manager
import android.annotation.SuppressLint
import com.google.common.truth.Truth
import com.squareup.moshi.Moshi
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.core.configtoggle.storage.ConfigToggle
@ -12,6 +13,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -24,7 +26,11 @@ import kotlin.collections.set
internal class DevTogglesManagerTest {
private val localTogglesStorage = mockk<TogglesStorage>()
private val appPreferenceStore = mockk<AppPreferencesStore>(relaxed = true)
private val appPreferenceStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = mockk(relaxed = true),
)
private val versionProvider = mockk<VersionProvider>()
private val manager = DevFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,

View file

@ -8,6 +8,7 @@ import com.squareup.moshi.Types
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -24,7 +25,11 @@ internal class LocalTogglesStorageTest {
private val jsonAdapter = mockk<JsonAdapter<List<ConfigToggle>>>()
// Impossible to mockk AssetLoader because it implement inline functions
private val assetLoader = AssetLoader(assetReader = assetReader, moshi = moshi)
private val assetLoader = AssetLoader(
assetReader = assetReader,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val storage = LocalTogglesStorage(assetLoader)

View file

@ -7,10 +7,13 @@ import com.squareup.moshi.JsonClass
data class CardActivationRemoteStateResponse(
@Json(name = "activation_status") val status: String,
@Json(name = "activation_order") val activationOrder: ActivationOrder?,
@Json(name = "stepChangeCode") val stepChangeCode: Int?,
@Json(name = "updatedAt") val updatedAt: String?,
) {
@JsonClass(generateAdapter = true)
data class ActivationOrder(
@Json(name = "id") val id: String,
@Json(name = "customer_id") val customerId: String,
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
)
}

View file

@ -0,0 +1,18 @@
package com.tangem.datasource.appcurrency
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import kotlinx.coroutines.flow.Flow
/**
* Store of app currency data model [CurrenciesResponse.Currency]
*
[REDACTED_AUTHOR]
*/
interface AppCurrencyResponseStore {
/** Get flow of [CurrenciesResponse.Currency] */
fun get(): Flow<CurrenciesResponse.Currency?>
/** Get [CurrenciesResponse.Currency] synchronously or null */
suspend fun getSyncOrNull(): CurrenciesResponse.Currency?
}

View file

@ -0,0 +1,28 @@
package com.tangem.datasource.appcurrency
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import kotlinx.coroutines.flow.Flow
/**
* Default implementation of [AppCurrencyResponseStore]
*
* @property appPreferencesStore app preferences store
*/
internal class DefaultAppCurrencyResponseStore(
private val appPreferencesStore: AppPreferencesStore,
) : AppCurrencyResponseStore {
override fun get(): Flow<CurrenciesResponse.Currency?> {
return appPreferencesStore.getObject(PreferencesKeys.SELECTED_APP_CURRENCY_KEY)
}
override suspend fun getSyncOrNull(): CurrenciesResponse.Currency? {
return appPreferencesStore.getObjectSyncOrNull<CurrenciesResponse.Currency>(
PreferencesKeys.SELECTED_APP_CURRENCY_KEY,
)
}
}

View file

@ -3,8 +3,10 @@ package com.tangem.datasource.asset.loader
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.adapter
import com.tangem.utils.coroutines.runCatching
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -23,69 +25,63 @@ import javax.inject.Singleton
class AssetLoader @Inject constructor(
val assetReader: AssetReader,
@NetworkMoshi val moshi: Moshi,
val dispatchers: CoroutineDispatcherProvider,
) {
/** Load content [Content] of asset file [fileName] */
@OptIn(ExperimentalStdlibApi::class)
suspend inline fun <reified Content> load(fileName: String): Content? {
return runCatching {
val json = assetReader.read(fullFileName = "$fileName.json")
suspend inline fun <reified Content> load(fileName: String): Content? = runCatching(dispatchers.io) {
val json = assetReader.read(fullFileName = "$fileName.json")
moshi.adapter<Content>().fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
null
},
)
moshi.adapter<Content>().fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
null
},
)
/** Load list [V] values of asset file [fileName] */
suspend inline fun <reified V> loadList(fileName: String): List<V> {
return runCatching {
val json = assetReader.read(fullFileName = "$fileName.json")
suspend inline fun <reified V> loadList(fileName: String): List<V> = runCatching(dispatchers.io) {
val json = assetReader.read(fullFileName = "$fileName.json")
val type = Types.newParameterizedType(List::class.java, V::class.java)
val adapter = moshi.adapter<List<V>>(type)
val type = Types.newParameterizedType(List::class.java, V::class.java)
val adapter = moshi.adapter<List<V>>(type)
adapter.fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
emptyList()
},
)
adapter.fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
emptyList()
},
)
/** Load map [String] keys and [V] values of asset file [fileName] */
suspend inline fun <reified V> loadMap(fileName: String): Map<String, V> {
return runCatching {
val json = assetReader.read(fullFileName = "$fileName.json")
suspend inline fun <reified V> loadMap(fileName: String): Map<String, V> = runCatching(dispatchers.io) {
val json = assetReader.read(fullFileName = "$fileName.json")
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
adapter.fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
emptyMap()
},
)
adapter.fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config [$fileName] from assets")
emptyMap()
},
)
}

View file

@ -1,23 +1,19 @@
package com.tangem.datasource.asset.reader
import android.content.res.AssetManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.io.BufferedReader
/**
* Implementation of asset file reader
*
* @property assetManager asset manager
* @property dispatchers dispatchers
*/
internal class AndroidAssetReader(
private val assetManager: AssetManager,
private val dispatchers: CoroutineDispatcherProvider,
) : AssetReader {
override suspend fun read(fullFileName: String): String = withContext(dispatchers.io) {
assetManager.open(fullFileName).bufferedReader()
override suspend fun read(fullFileName: String): String {
return assetManager.open(fullFileName).bufferedReader()
.use(BufferedReader::readText)
}
}

View file

@ -1,8 +1,11 @@
package com.tangem.datasource.di
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.datasource.appcurrency.DefaultAppCurrencyResponseStore
import com.tangem.datasource.local.appcurrency.AvailableAppCurrenciesStore
import com.tangem.datasource.local.appcurrency.implementation.DefaultAvailableAppCurrenciesStore
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -18,4 +21,10 @@ internal object AppCurrencyDataModule {
fun provideAvailableAppCurrenciesStore(): AvailableAppCurrenciesStore {
return DefaultAvailableAppCurrenciesStore(dataStore = RuntimeDataStore())
}
@Provides
@Singleton
fun provideAppCurrencyResponseStore(appPreferencesStore: AppPreferencesStore): AppCurrencyResponseStore {
return DefaultAppCurrencyResponseStore(appPreferencesStore)
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.datasource.di
import android.content.Context
import com.squareup.moshi.Moshi
import com.tangem.datasource.local.*
import com.tangem.datasource.local.preferences.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -26,6 +25,7 @@ internal object AppPreferencesStoreModule {
return AppPreferencesStore(
preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, dispatcher = dispatchers.io),
moshi = moshi,
dispatchers = dispatchers,
)
}
}

View file

@ -3,7 +3,6 @@ package com.tangem.datasource.di
import android.content.Context
import com.tangem.datasource.asset.reader.AndroidAssetReader
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -17,10 +16,7 @@ internal object AssetReaderModule {
@Singleton
@Provides
fun providesAsserReader(
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): AssetReader {
return AndroidAssetReader(context.assets, dispatchers)
fun providesAsserReader(@ApplicationContext context: Context): AssetReader {
return AndroidAssetReader(context.assets)
}
}

View file

@ -51,12 +51,14 @@ class MoshiModule {
PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc")
.withSubtype(NFTCollection.Identifier.EVM::class.java, "evm")
.withSubtype(NFTCollection.Identifier.TON::class.java, "ton")
.withSubtype(NFTCollection.Identifier.Solana::class.java, "sol")
.withDefaultValue(NFTCollection.Identifier.Unknown),
)
.add(
PolymorphicJsonAdapterFactory.of(NFTAsset.Identifier::class.java, "bc")
.withSubtype(NFTAsset.Identifier.EVM::class.java, "evm")
.withSubtype(NFTAsset.Identifier.TON::class.java, "ton")
.withSubtype(NFTAsset.Identifier.Solana::class.java, "sol")
.withDefaultValue(NFTAsset.Identifier.Unknown),
)
.addLast(KotlinJsonAdapterFactory())

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
@ -26,21 +27,27 @@ internal object QuotesStoreModule {
@Provides
@Singleton
fun provideQuotesStore(
fun providePersistenceQuotesStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): QuotesStore {
return DefaultQuotesStore(
persistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<QuotesResponse.Quote>(),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "quotes") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
): DataStore<Map<String, QuotesResponse.Quote>> {
return DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<QuotesResponse.Quote>(),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "quotes") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}
@Provides
@Singleton
fun provideQuotesStore(persistenceStore: DataStore<Map<String, QuotesResponse.Quote>>): QuotesStore {
return DefaultQuotesStore(
persistenceStore = persistenceStore,
runtimeStore = RuntimeSharedStore(),
)
}

View file

@ -37,8 +37,12 @@ class AppLogsStore @Inject constructor(
private val mutex = Mutex()
private val zipMutex = Mutex()
private val file = File(applicationContext.filesDir, PERMITTED_FILE_NAME)
private val fileZip = File(applicationContext.filesDir, PERMITTED_FILE_NAME_ZIP)
private val logFile by lazy {
File(applicationContext.filesDir, PERMITTED_FILE_NAME)
}
private val logFileZip by lazy {
File(applicationContext.filesDir, PERMITTED_FILE_NAME_ZIP)
}
private val formatter = DateTimeFormatterBuilder()
.appendDayOfMonth(2)
@ -55,12 +59,12 @@ class AppLogsStore @Inject constructor(
.toFormatter()
/** Get log file */
fun getFile(): File? = if (file.exists()) file else null
fun getFile(): File? = if (logFile.exists()) logFile else null
suspend fun getZipFile(): File? {
return zipMutex.withLock {
if (file.exists()) {
zip(listOf(file), fileZip)
if (logFile.exists()) {
zip(listOf(logFile), logFileZip)
} else {
null
}
@ -98,8 +102,8 @@ class AppLogsStore @Inject constructor(
/** Delete deprecated logs if file size exceeds [maxSize] */
fun deleteDeprecatedLogs(maxSize: Int) {
launchWithLock {
if (file.exists() && file.length() > maxSize) {
file.delete()
if (logFile.exists() && logFile.length() > maxSize) {
logFile.delete()
}
}
}
@ -117,7 +121,7 @@ class AppLogsStore @Inject constructor(
}
private fun writeMessage(tag: String, vararg messages: String) {
BufferedWriter(FileWriter(file, true)).use { writer ->
BufferedWriter(FileWriter(logFile, true)).use { writer ->
writer.append(formatter.print(DateTime.now()))
writer.append(": $tag ")
messages.forEach(writer::append)
@ -126,8 +130,8 @@ class AppLogsStore @Inject constructor(
}
private fun createFileIfNotExist() {
if (!file.exists()) {
runCatching { file.createNewFile() }
if (!logFile.exists()) {
runCatching { logFile.createNewFile() }
.onFailure(Timber::e)
}
}

View file

@ -13,6 +13,10 @@ object NFTSdkAssetIdentifierConverter : TwoWayConverter<SdkNFTAsset.Identifier,
is SdkNFTAsset.Identifier.TON -> NFTAsset.Identifier.TON(
tokenAddress = value.tokenAddress,
)
is SdkNFTAsset.Identifier.Solana -> NFTAsset.Identifier.Solana(
tokenAddress = value.tokenAddress,
cnft = value.cnft,
)
is SdkNFTAsset.Identifier.Unknown -> NFTAsset.Identifier.Unknown
}
@ -24,6 +28,10 @@ object NFTSdkAssetIdentifierConverter : TwoWayConverter<SdkNFTAsset.Identifier,
is NFTAsset.Identifier.TON -> SdkNFTAsset.Identifier.TON(
tokenAddress = value.tokenAddress,
)
is NFTAsset.Identifier.Solana -> SdkNFTAsset.Identifier.Solana(
tokenAddress = value.tokenAddress,
cnft = value.cnft,
)
is NFTAsset.Identifier.Unknown -> SdkNFTAsset.Identifier.Unknown
}
}

View file

@ -12,6 +12,9 @@ object NFTSdkCollectionIdentifierConverter : TwoWayConverter<SdkNFTCollection.Id
is SdkNFTCollection.Identifier.TON -> NFTCollection.Identifier.TON(
contractAddress = value.contractAddress,
)
is SdkNFTCollection.Identifier.Solana -> NFTCollection.Identifier.Solana(
collection = value.collection,
)
is SdkNFTCollection.Identifier.Unknown -> NFTCollection.Identifier.Unknown
}
@ -22,6 +25,9 @@ object NFTSdkCollectionIdentifierConverter : TwoWayConverter<SdkNFTCollection.Id
is NFTCollection.Identifier.TON -> SdkNFTCollection.Identifier.TON(
contractAddress = value.contractAddress,
)
is NFTCollection.Identifier.Solana -> SdkNFTCollection.Identifier.Solana(
collection = value.collection,
)
is NFTCollection.Identifier.Unknown -> SdkNFTCollection.Identifier.Unknown
}
}

View file

@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
/**
* Application preferences store.
@ -19,6 +20,7 @@ import com.squareup.moshi.Types
*/
class AppPreferencesStore(
val moshi: Moshi,
val dispatchers: CoroutineDispatcherProvider,
private val preferencesDataStore: DataStore<Preferences>,
) : DataStore<Preferences> by preferencesDataStore {

View file

@ -5,23 +5,25 @@ import androidx.datastore.preferences.core.edit
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Types
import com.tangem.datasource.local.preferences.AppPreferencesStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
/** Get flow of nullable data [T] by string [key] */
inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String>): Flow<T?> {
val adapter = moshi.adapter(T::class.java)
return data.map { preferences ->
preferences[key]?.let {
try {
adapter.fromJson(it)
} catch (e: JsonDataException) {
null
}
}
}.distinctUntilChanged()
return flow {
val adapter = moshi.adapter(T::class.java)
emitAll(
data.map { preferences ->
preferences[key]?.let {
try {
adapter.fromJson(it)
} catch (e: JsonDataException) {
null
}
}
}.distinctUntilChanged(),
)
}
}
/**
@ -32,16 +34,19 @@ inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String
* @see getObjectList
* */
inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String>, default: T): Flow<T> {
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
return data.map {
try {
it[key]?.let(adapter::fromJson) ?: default
} catch (e: JsonDataException) {
default
}
}.distinctUntilChanged()
return flow {
val adapter = moshi.adapter(T::class.java)
emitAll(
data.map {
try {
it[key]?.let(adapter::fromJson) ?: default
} catch (e: JsonDataException) {
default
}
}.distinctUntilChanged(),
)
}
}
/**
* Get nullable data [T] by string [key]
*
@ -49,26 +54,27 @@ inline fun <reified T> AppPreferencesStore.getObject(key: Preferences.Key<String
*
* @see getObjectListSync
* */
suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key<String>): T? {
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
return data.firstOrNull()
?.get(key)
?.let {
try {
adapter.fromJson(it)
} catch (e: JsonDataException) {
null
suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key<String>): T? =
withContext(dispatchers.io) {
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
data.firstOrNull()
?.get(key)
?.let {
try {
adapter.fromJson(it)
} catch (e: JsonDataException) {
null
}
}
}
}
}
/** Get data [T] by string [key]. If data is not found, it returns [default] */
suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrDefault(
key: Preferences.Key<String>,
default: T,
): T {
): T = withContext(dispatchers.io) {
val adapter = moshi.adapter(T::class.java)
return data.firstOrNull()
data.firstOrNull()
?.get(key)
?.let {
try {
@ -87,37 +93,47 @@ suspend inline fun <reified T> AppPreferencesStore.getObjectSyncOrDefault(
*
* @see storeObjectList
* */
suspend inline fun <reified T> AppPreferencesStore.storeObject(key: Preferences.Key<String>, value: T) {
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
edit { it[key] = adapter.toJson(value) }
}
@Suppress("OptionalUnit")
suspend inline fun <reified T> AppPreferencesStore.storeObject(key: Preferences.Key<String>, value: T): Unit =
withContext(dispatchers.io) {
val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types
edit { it[key] = adapter.toJson(value) }
}
/** Store list of data [value] by string [key] */
suspend inline fun <reified T> AppPreferencesStore.storeObjectList(key: Preferences.Key<String>, value: List<T>) {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
edit { it[key] = adapter.toJson(value) }
}
suspend inline fun <reified T> AppPreferencesStore.storeObjectList(key: Preferences.Key<String>, value: List<T>) =
withContext(dispatchers.io) {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
edit { it[key] = adapter.toJson(value) }
}
/** Get flow of list of data [T] by string [key]. If data is not found, it returns `null` */
inline fun <reified T> AppPreferencesStore.getObjectList(key: Preferences.Key<String>): Flow<List<T>?> {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
return data.map { it[key]?.let(adapter::fromJson) }.distinctUntilChanged()
return flow {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
emitAll(
data.map {
it[key]?.let(adapter::fromJson)
}.distinctUntilChanged(),
)
}
}
/** Get list of data [T] by string [key], or empty if data is not found */
suspend inline fun <reified T> AppPreferencesStore.getObjectListSync(key: Preferences.Key<String>): List<T> {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
return data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
suspend inline fun <reified T> AppPreferencesStore.getObjectListSync(key: Preferences.Key<String>): List<T> =
withContext(dispatchers.io) {
val adapter = moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java))
data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
/** Store map with [String] key and value [V] by string [key] */
suspend inline fun <reified V> AppPreferencesStore.storeObjectMap(
key: Preferences.Key<String>,
value: Map<String, V>,
) {
) = withContext(dispatchers.io) {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
@ -125,37 +141,47 @@ suspend inline fun <reified V> AppPreferencesStore.storeObjectMap(
}
/** Get map with [String] key and value [V] by string [key], or empty if data is not found */
suspend inline fun <reified V> AppPreferencesStore.getObjectMapSync(key: Preferences.Key<String>): Map<String, V> {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
suspend inline fun <reified V> AppPreferencesStore.getObjectMapSync(key: Preferences.Key<String>): Map<String, V> =
withContext(dispatchers.io) {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
return data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
/** Get flow of map with [String] key and value [V] by string [key], or empty if data is not found */
inline fun <reified V> AppPreferencesStore.getObjectMap(key: Preferences.Key<String>): Flow<Map<String, V>> {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
return flow {
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
return data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() }
emitAll(
data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() },
)
}
}
/** Get set of data [T] by string [key], or empty if data is not found */
suspend inline fun <reified T> AppPreferencesStore.getObjectSetSync(key: Preferences.Key<String>): Set<T> {
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
return data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
suspend inline fun <reified T> AppPreferencesStore.getObjectSetSync(key: Preferences.Key<String>): Set<T> =
withContext(dispatchers.io) {
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
data.firstOrNull()
?.get(key)
?.let(adapter::fromJson)
.orEmpty()
}
/** Get flow of set of [T] by string [key], or empty if data is not found */
inline fun <reified T> AppPreferencesStore.getObjectSet(key: Preferences.Key<String>): Flow<Set<T>> {
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
return data.map {
it[key]?.let(adapter::fromJson) ?: emptySet()
return flow {
val adapter = moshi.adapter<Set<T>>(Types.newParameterizedType(Set::class.java, T::class.java))
emitAll(
data.map {
it[key]?.let(adapter::fromJson) ?: emptySet()
},
)
}
}

View file

@ -22,7 +22,9 @@ internal class SharedPreferencesKeyMigration(
private val keyName: String,
) : DataMigration<Preferences> {
private val legacyPrefs = context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE)
private val legacyPrefs by lazy {
context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE)
}
override suspend fun cleanUp() {
val sharedPrefsEditor = legacyPrefs.edit()

View file

@ -10,13 +10,24 @@ import com.tangem.utils.extensions.orZero
/**
* Converter from [QuotesResponse.Quote] to [Quote.Value]
*
* @property isCached flag that determines whether the quote is a cache
* @property source status source
*
[REDACTED_AUTHOR]
*/
internal class QuoteConverter(private val isCached: Boolean) :
class QuoteConverter(
private val source: StatusSource,
) :
Converter<Map.Entry<String, QuotesResponse.Quote>, Quote.Value> {
/**
* Secondary constructor
*
* @param isCached flag that determines whether the quote is a cache
*/
constructor(isCached: Boolean) : this(
source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL,
)
override fun convert(value: Map.Entry<String, QuotesResponse.Quote>): Quote.Value {
val (currencyId, quote) = value
@ -24,7 +35,7 @@ internal class QuoteConverter(private val isCached: Boolean) :
rawCurrencyId = CryptoCurrency.RawID(currencyId),
fiatRate = quote.price.orZero(),
priceChange = quote.priceChange24h.orZero().movePointLeft(2),
source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL,
source = source,
)
}
}

View file

@ -7,6 +7,7 @@ import com.squareup.moshi.Types
import com.squareup.moshi.adapter
import com.tangem.datasource.api.express.models.response.Asset
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerifyOrder
import io.mockk.every
@ -22,7 +23,11 @@ class AssetLoaderTest {
private val assetReader = mockk<AssetReader>()
private val moshi = mockk<Moshi>()
private val assetLoader = AssetLoader(assetReader = assetReader, moshi = moshi)
private val assetLoader = AssetLoader(
assetReader = assetReader,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun load() = runTest {

View file

@ -2,7 +2,6 @@ package com.tangem.datasource.asset.reader
import android.content.res.AssetManager
import com.google.common.truth.Truth
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
@ -15,7 +14,7 @@ import java.io.IOException
internal class AndroidAssetReaderTest {
private val assetManager = mockk<AssetManager>()
private val assetReader = AndroidAssetReader(assetManager, TestingCoroutineDispatcherProvider())
private val assetReader = AndroidAssetReader(assetManager)
@Test
fun read_content() = runTest {

View file

@ -503,6 +503,12 @@
<item quantity="one">%d Stück</item>
<item quantity="other">%d Stücke</item>
</plurals>
<string name="nft_collections_empty_description">NFTs, die an Deine Wallet-Adresse gesendet werden, werden hier angezeigt.</string>
<string name="nft_collections_empty_title">Noch keine Kollektionen</string>
<string name="nft_collections_receive">NFT erhalten</string>
<string name="nft_collections_title">NFT-Kollektionen</string>
<string name="nft_collections_warning_subtitle">Einige Daten werden möglicherweise nicht geladen</string>
<string name="nft_collections_warning_title">Vorübergehende Ladeprobleme</string>
<string name="nft_wallet_count">%1$d NFTs in der %2$d Sammlung</string>
<string name="nft_wallet_receive_nft">Tippe hier, um das erste NFT zu erhalten</string>
<string name="nft_wallet_title">NFT-Sammlungen</string>
@ -522,7 +528,7 @@
<string name="onboarding_activation_error_message">Bitte wiederhole den Vorgang. Die Karte oder Ring wird auf Werkseinstellungen zurückgesetzt.</string>
<string name="onboarding_activation_error_title">Aktivierungsfehler</string>
<string name="onboarding_add_tokens">Token hinzufügen</string>
<string name="onboarding_alert_message_not_max_backup_cards_added">Du hast einee Backup-Karte oder einen Backup-Ring hinzugefügt. Wenn der Backup-Prozess abgeschlossen ist, kannst Du keine weiteren Backup-Geräte hinzufügen. Wenn Du noch eine Karte oder einen Ring hast, fügen diese(n) zum Backup hinzu. Möchtest Du den Backup-Prozess fortsetzen?</string>
<string name="onboarding_alert_message_not_max_backup_cards_added">Du hast eine Backup-Karte oder einen Backup-Ring hinzugefügt. Nach Abschluss des Backup-Vorgangs kannst Du keine weiteren Backup-Geräte hinzufügen. Wenn Du eine weitere Karte oder einen weiteren Ring hast, fügen diesen dem Backup jetzt hinzu. Möchtest Du den Backup-Vorgang fortsetzen?</string>
<string name="onboarding_backup_exit_warning">Der Sicherungsvorgang ist teilweise abgeschlossen. Du kannst ihn jetzt nicht beenden.</string>
<string name="onboarding_bottom_sheet_passphrase_description">Die Passphrase ist eine fortschrittliche Sicherheitsfunktion, die von Krypto-Wallets verwendet wird. Sie fügt ein zusätzliches Wort oder eine Phrase deiner Wahl zu der bereits bestehenden Wiederherstellungsphrase hinzu, um einen brandneuen Satz von Adressen zu erzeugen.</string>
<string name="onboarding_button_add_backup_card">Hinzufügen einer Sicherungskarte oder Ring</string>
@ -1165,6 +1171,7 @@
<string name="warning_token_balance_not_updated">Der Kontostand ist möglicherweise veraltet. Aktualisiere bittre die Seite.</string>
<string name="wc_connections">Verbindungen</string>
<string name="wc_disconnect_all">Alle trennen</string>
<string name="wc_disconnect_all_alert_desc">Text über die Trennung aller dApps</string>
<string name="wc_disconnect_all_alert_title">Alle dApps trennen</string>
<string name="wc_new_connection">Neue Verbindung</string>
<string name="wc_no_sessions_desc">Verbinde Deine Wallet mit einer anderen dApp</string>

View file

@ -140,6 +140,7 @@
<string name="common_fee_selector_option_market">Marché</string>
<string name="common_fee_selector_option_slow">Lent</string>
<string name="common_fee_selector_title">Vitesse et frais</string>
<string name="common_finish">Terminer</string>
<string name="common_generate_addresses">Synchroniser les adresses</string>
<string name="common_go_to_provider">Aller au fournisseur</string>
<string name="common_go_to_token">Aller au jeton</string>
@ -159,6 +160,7 @@
<string name="common_no_address">Aucune adresse</string>
<string name="common_now">Maintenant</string>
<string name="common_ok">OK</string>
<string name="common_open_in_browser">Ouvrir dans le navigateur</string>
<string name="common_origin_card">Carte principale</string>
<string name="common_origin_ring">Bague principale</string>
<string name="common_passphrase">Passphrase</string>
@ -182,6 +184,7 @@
<string name="common_send">Envoyer</string>
<string name="common_server_unavailable">Le serveur n\'est pas disponible, veuillez réessayer plus tard</string>
<string name="common_share">Partager</string>
<string name="common_share_link">Partager le lien</string>
<string name="common_sign">Signez</string>
<string name="common_sign_and_send">Signez et envoyez</string>
<string name="common_stake">Stake</string>
@ -496,6 +499,16 @@
<string name="markets_tooltip_message">Tirez vers le haut ou appuyez sur la barre de recherche pour ajouter des jetons directement depuis le marché</string>
<string name="markets_tooltip_title">Ajouter des jetons</string>
<string name="nfc_error_unavailable">NFC n\'est pas disponible sur votre appareil</string>
<string name="nft_collections_empty_description">Les NFT envoyés à l\'adresse de votre portefeuille s\'afficheront ici.</string>
<string name="nft_collections_empty_title">Aucune collection pour le moment</string>
<string name="nft_collections_receive">Recevoir des NFT</string>
<string name="nft_collections_title">Collections NFT</string>
<string name="nft_collections_warning_subtitle">Certaines données peuvent ne pas se charger</string>
<string name="nft_collections_warning_title">Problèmes de chargement temporaires</string>
<string name="nft_wallet_count">%1$d NFT dans la collection %2$d</string>
<string name="nft_wallet_receive_nft">Appuyez ici pour recevoir le premier NFT</string>
<string name="nft_wallet_title">Collections NFT</string>
<string name="nft_wallet_unable_to_load">Impossible de charger les données</string>
<string name="onboarding_access_code_feature_1_description">Vous devez définir un seul code d\'accès pour protéger tous vos appareils.</string>
<string name="onboarding_access_code_feature_1_title">Protéger</string>
<string name="onboarding_access_code_feature_2_description">Vous pourrez définir un code d\'accès individuel sur chaque carte plus tard</string>
@ -762,6 +775,8 @@
<string name="send_summary_transaction_description_suffix_including">y compris des frais de réseau de %1$s</string>
<string name="send_transaction_success">La transaction a été signée avec succès et envoyée au nœud de blockchain. Le solde du portefeuille sera mis à jour après un certain temps</string>
<string name="send_tron_account_activation_error">%1$s est un actif du réseau Tron. Pour calculer les frais et effectuer une transaction, déposez du Tron (TRX) sur votre compte.</string>
<string name="send_validation_destination_tag_required_description">Une balise de destination (mémo) est requise pour terminer cette transaction pour l\'adresse spécifiée.</string>
<string name="send_validation_destination_tag_required_title">Étiquette de destination requise</string>
<string name="sent_transaction_sent_title">Transaction envoyée</string>
<string name="settings_card_settings_footer">Scannez la carte/ bague que vous souhaitez configurer.</string>
<string name="settings_forget_wallet">Oublier le portefeuille</string>
@ -783,6 +798,7 @@
<string name="staking_details_estimated_profit">%s profit estimatif</string>
<string name="staking_details_market_rating">Cote du marché</string>
<string name="staking_details_metrics_block_header">Métriques</string>
<string name="staking_details_min_rewards_notification">Selon les règles du réseau %1$s, les réclamations sont possibles à partir de %2$s. Les montants ci-dessous seront crédités sur votre compte lors du déblocage.</string>
<string name="staking_details_minimum_requirement">Minimum requis</string>
<string name="staking_details_no_rewards_to_claim">Aucune récompense à réclamer</string>
<string name="staking_details_reward_claiming">Réclamation de récompense</string>
@ -820,11 +836,16 @@
<string name="staking_notification_low_staked_balance_title">Solde de staking faible</string>
<string name="staking_notification_minimum_balance_error_text">Un minimum de %1$s %2$s est requis pour le re-staking. Veuillez recharger votre solde.</string>
<string name="staking_notification_minimum_balance_error_title">Pas assez de %s</string>
<string name="staking_notification_minimum_balance_title">Solde insuffisant pour le staking</string>
<string name="staking_notification_minimum_restake_ada_text">Un minimum de 3 ADA est requis pour le re-staking. Veuillez recharger votre solde.</string>
<string name="staking_notification_minimum_restake_ada_title">ADA insuffisants</string>
<string name="staking_notification_minimum_stake_ada_text">Le montant minimum requis pour le staking doit être supérieur à 5 ADA. Veuillez recharger votre solde pour commencer à staking.</string>
<string name="staking_notification_network_error_text">L\'option de staking n\'est actuellement pas disponible en raison des conditions du réseau. Veuillez réessayer plus tard.</string>
<string name="staking_notification_new_validator_funds_transfer">Le staking dans le réseau %1$s avec un nouveau validateur transférera automatiquement tous les fonds précédemment stakés vers ce validateur</string>
<string name="staking_notification_restake_rewards_text">Réinvestissez vos récompenses gagnées dans le montant que vous avez staké, augmentant ainsi vos gains potentiels.</string>
<string name="staking_notification_restake_text">L\'option de restaker vous permet de déplacer vos fonds d\'un validateur à un autre sans avoir besoin de les déstaker.</string>
<string name="staking_notification_stake_entire_balance_text">Vous êtes sur le point de staker l\'intégralité de votre solde. Nous vous recommandons de laisser un petit montant pour couvrir les frais de réseau pour unstaking ou la réclamation des récompenses.</string>
<string name="staking_notification_ton_activate_account">Pour commencer à staker TON, effectuez d\'abord une transaction sortante de n\'importe quel montant — cela activera votre portefeuille.</string>
<string name="staking_notification_unlock_text">Débloquez votre argent pour le retirer du processus de staking. Le déverrouillage prend %s.</string>
<string name="staking_notification_unstake_cosmos_text">Vos fonds seront disponibles à l\'utilisation après la période de déblocage de 21 jours. La récompense sera retirée en même temps que vos fonds de déblocage.</string>
<string name="staking_notification_unstake_text">Vos fonds seront disponibles pour utilisation après la période de désengagement %s.</string>
@ -853,6 +874,7 @@
<string name="staking_rewards">Récompenses</string>
<string name="staking_stake_locked">Stake verrouillé</string>
<string name="staking_stake_more">Staker plus</string>
<string name="staking_stake_more_button_unavailability_reason">Lorsque vous stakez %1$s, la totalité de votre solde %2$s est stakeée. Tout dépôt supplémentaire de %2$s sur votre portefeuille Tangem sera également staké automatiquement.</string>
<string name="staking_staked_amount">Montant staké</string>
<string name="staking_summary_description_text">Vous stakez %1$s et recevrez %2$s</string>
<string name="staking_tap_to_unlock">Appuyez pour déverrouiller</string>
@ -887,6 +909,8 @@
<string name="story_meet_title">Découvrez Tangem</string>
<string name="story_web3_description">Échangez, achetez des NFT, faites des prêts et des dépôts dans plus de 100 services décentralisés différents</string>
<string name="story_web3_title">Compatible avec Web 3.0</string>
<string name="sui_not_enough_coin_for_fee_description">Une transaction entrante d\'au moins de %1$s est requise pour continuer</string>
<string name="sui_not_enough_coin_for_fee_title">Fonds insuffisants</string>
<string name="swap_give_permission_fee_footer">Le réseau facturera des frais d\'approbation de jeton pour vérifier que vous autorisez l\'utilisation de votre jeton pour l\'échange.</string>
<string name="swap_promo_text">Échangez plus de jetons à de meilleurs taux directement dans votre portefeuille.</string>
<string name="swap_promo_title">Nouveau fournisseur d\'échange disponible !</string>
@ -926,6 +950,7 @@
<string name="token_button_unavailability_reason_empty_balance_send">Vous n\'avez pas de fonds à envoyer. Renflouez votre compte pour pouvoir envoyer des fonds à partir de celui-ci.</string>
<string name="token_button_unavailability_reason_loading">Die Daten wurden noch nicht geladen. Dies kann einige Sekunden dauern. Bitte versuchen Sie es später noch einmal.</string>
<string name="token_button_unavailability_reason_not_exchangeable">Le service d\'échange %s n\'est pas pris en charge par les fournisseurs actuels mais nous travaillons à ajouter plus d\'options.</string>
<string name="token_button_unavailability_reason_out_of_date_balance">Le solde affiché peut être obsolète en raison de la mise en cache.</string>
<string name="token_button_unavailability_reason_pending_transaction_sell">La vente de fonds sera disponible une fois que la ou les transactions en attente dans le réseau %s seront terminées</string>
<string name="token_button_unavailability_reason_pending_transaction_send">L\'envoi de fonds sera disponible une fois la ou les transactions en attente dans le réseau %s terminées.</string>
<string name="token_button_unavailability_reason_sell_unavailable">L\'achat de %s n\'est pas pris en charge par les fournisseurs actuels mais nous travaillons à ajouter plus d\'options.</string>
@ -972,6 +997,7 @@
<string name="twins_recreate_toolbar">Tangem Twin</string>
<string name="twins_recreate_warning">Cette action est irréversible. Vous n\'aurez plus accès à l\'ancien portefeuille.</string>
<string name="twins_scan_twin_with_number">Appuyez sur la carte jumelle avec le numéro %s et ne la retirez pas jusqu\'à la fin de l\'opération</string>
<string name="universal_error">Nous avons rencontré une erreur. Code d\'erreur : %s. Veuillez contacter notre équipe de support.</string>
<string name="unlock_wallet_description_full">Utilisez %s ou scannez une carte/bague pour avoir accès à votre portefeuille</string>
<string name="unsupported_wc_version">Échec de la connexion : Cette dApp utilise Wallet Connect version1.0, qui n\'est pas prise en charge. Veuillez vous assurer que la dApp prend en charge Wallet Connect version2.0 pour réussir la connexion.</string>
<string name="user_push_notification_agreement_argument_one">Restez à jour avec les dernières fonctionnalités et actualités</string>
@ -986,6 +1012,24 @@
<string name="user_wallet_list_rename_popup_title">Renommer le portefeuille</string>
<string name="user_wallet_list_unlock_all">Tout déverrouiller</string>
<string name="user_wallet_list_unlock_all_with">Tout déverrouiller avec %s</string>
<plurals name="visa_limits_available_for_days_title">
<item quantity="one">disponible pour %d jour</item>
<item quantity="other">disponible pour %d jours</item>
</plurals>
<string name="visa_main_balances_and_limits">Soldes et Limites</string>
<string name="visa_onboarding_close_alert_message">Êtes-vous sûr de vouloir quitter ? Vous pourrez reprendre plus tard là où vous vous étiez arrêté.</string>
<string name="visa_onboarding_in_progress_description">Cela ne prendra pas longtemps. Nous configurons votre compte.</string>
<string name="visa_onboarding_in_progress_issuer_description">Cela ne prendra pas longtemps. Nous terminons l\'activation.</string>
<string name="visa_onboarding_in_progress_title">Tout est en cours de préparation !</string>
<string name="visa_onboarding_pin_validation_error_message">Code PIN invalide : évitez les séquences ou les répétitions</string>
<string name="visa_onboarding_wallet_connect_title">Accéder le site Web</string>
<string name="visa_onboarding_welcome_back_description">Continuons la configuration de votre compte.</string>
<string name="visa_onboarding_welcome_back_title">Content de vous revoir !</string>
<string name="visa_onboarding_welcome_description">Suivez les étapes pour configurer votre compte.</string>
<string name="visa_onboarding_welcome_title">Bienvenue !</string>
<string name="visa_unlock_notification_button">Déverrouiller</string>
<string name="visa_unlock_notification_subtitle">Scannez votre carte pour déverrouiller l\'accès</string>
<string name="visa_unlock_notification_title">Déverrouillage nécessaire</string>
<string name="wallet_balance_blockchain_unreachable_try_later">La blockchain n\'est pas accessible. Réessayez plus tard</string>
<string name="wallet_balance_missing_derivation">Scanner la carte ou la bague</string>
<string name="wallet_been_activated_message">Ce portefeuille a déjà été activé auparavant.\nSi cela n\'a pas été fait par vous, veuillez contacter le support.\nTangem ne vend jamais de portefeuilles avec le code d\'accès pré-généré.</string>
@ -1116,6 +1160,13 @@
<string name="warning_testnet_card_message">Il s\'agit d\'une carte Testnet. Elle ne peut pas traiter les transactions et ne doit être utilisée qu\'à des fins de test et de développement.</string>
<string name="warning_testnet_card_title">À des fins de test uniquement</string>
<string name="warning_token_balance_not_updated">Le solde peut être obsolète. Rafraîchissez la page.</string>
<string name="wc_connections">Connexions</string>
<string name="wc_disconnect_all">Déconnecter tout</string>
<string name="wc_disconnect_all_alert_desc">Texte sur la déconnexion de toutes les dApps</string>
<string name="wc_disconnect_all_alert_title">Déconnecter toutes les dApps</string>
<string name="wc_new_connection">Nouvelle connexion</string>
<string name="wc_no_sessions_desc">Connectez votre portefeuille à différentes dApps</string>
<string name="wc_no_sessions_title">Aucune séance</string>
<string name="welcome_interrupted_backup_alert_discard">Ignorer</string>
<string name="welcome_interrupted_backup_alert_message">Vous avez une sauvegarde interrompue. Voulez-vous la reprendre ?</string>
<string name="welcome_interrupted_backup_alert_resume">Oui, reprendre</string>

View file

@ -109,6 +109,8 @@
<string name="common_claim_rewards">報酬を受け取る</string>
<string name="common_close">閉じる</string>
<string name="common_confirm">確認</string>
<string name="common_contact_tangem_support">Tangemサポートへ問い合わせる</string>
<string name="common_contact_visa_support">Visaサポートへ問い合わせる</string>
<string name="common_continue">続ける</string>
<string name="common_copy">コピー</string>
<string name="common_copy_address">アドレスをコピー</string>
@ -153,6 +155,7 @@
<string name="common_network_fee_title">ネットワーク手数料</string>
<string name="common_network_fee_warning_content">送金額は、選択された手数料レベルをカバーするため、%1$s (%2$s) 減額されます。</string>
<string name="common_next"></string>
<string name="common_nft">NFT</string>
<string name="common_no">いいえ</string>
<string name="common_no_address">アドレスがありません</string>
<string name="common_now"></string>
@ -175,6 +178,7 @@
<string name="common_search">検索</string>
<string name="common_search_tokens">トークンを検索</string>
<string name="common_second_no_param"></string>
<string name="common_see_all">すべて見る</string>
<string name="common_seed_phrase">シードフレーズ</string>
<string name="common_select_action">アクションを選択</string>
<string name="common_sell">売る</string>
@ -502,6 +506,20 @@
<string name="nft_collections_title">NFTコレクション</string>
<string name="nft_collections_warning_subtitle">一部のデータが読み込まれない場合があります</string>
<string name="nft_collections_warning_title">一時的な読み込みの問題</string>
<string name="nft_details_base_information">基本情報</string>
<string name="nft_details_chain">チェーン</string>
<string name="nft_details_contract_address">コントラクトアドレス</string>
<string name="nft_details_last_sale_price">最終販売価格</string>
<string name="nft_details_rarity_label">レアリティ・ラベル</string>
<string name="nft_details_rarity_rank">レアリティ・ランク</string>
<string name="nft_details_token_address">トークンアドレス</string>
<string name="nft_details_token_id">トークンID</string>
<string name="nft_details_token_standard">トークン標準</string>
<string name="nft_details_traits">特徴</string>
<string name="nft_empty_search">結果がありません。別のリクエストをお試しください。</string>
<string name="nft_receive_choose_network">ネットワークを選択</string>
<string name="nft_receive_subtitle">私のウォレットへ</string>
<string name="nft_receive_title">NFTを受け取る</string>
<string name="nft_wallet_count">%1$dコレクションの%2$dNFT</string>
<string name="nft_wallet_receive_nft">ここをタップして最初のNFTを受け取ります</string>
<string name="nft_wallet_title">NFTコレクション</string>
@ -635,6 +653,7 @@
<string name="qr_scanner_camera_denied_title">カメラへのアクセスが拒否されました</string>
<string name="receive_bottom_sheet_no_memo_required_message">メモ不要</string>
<string name="receive_bottom_sheet_warning_message">%3$sネットワーク上の%1$s ( %2$s )</string>
<string name="receive_bottom_sheet_warning_message_compact">%2$sネットワーク上の%1$s</string>
<string name="receive_bottom_sheet_warning_message_description">他の暗号資産を送信すると、取り返しのつかない損失が発生します。</string>
<string name="receive_bottom_sheet_warning_message_full">このアドレスには%sのみを送金してください。他のトークンを送信すると、取り返しのつかない損失が発生します。</string>
<string name="receive_bottom_sheet_warning_title">%2$sネットワークの%1$sのみを送信してください</string>
@ -1015,12 +1034,14 @@
<string name="visa_onboarding_in_progress_description">長くはかかりません。アカウントを設定しています。</string>
<string name="visa_onboarding_in_progress_issuer_description">長くはかかりません。アクティベーションを完了しています。</string>
<string name="visa_onboarding_in_progress_title">準備完了です!</string>
<string name="visa_onboarding_pin_not_accepted">PINの認証に失敗しました。もう一度お試しいただくか、別のコードを使用してください。</string>
<string name="visa_onboarding_pin_validation_error_message">無効な暗証番号:連続や繰り返しを避けてください</string>
<string name="visa_onboarding_wallet_connect_title">ウェブサイトに移動</string>
<string name="visa_onboarding_welcome_back_description">アカウントの設定を続けましょう。</string>
<string name="visa_onboarding_welcome_back_title">お帰りなさい!</string>
<string name="visa_onboarding_welcome_description">手順に従ってアカウントを設定してください。</string>
<string name="visa_onboarding_welcome_title">ようこそ!</string>
<string name="visa_tx_dispute_button">この取引に異議を唱える</string>
<string name="visa_unlock_notification_button">ロック解除</string>
<string name="visa_unlock_notification_subtitle">カードをスキャンしてアクセスロックを解除する</string>
<string name="visa_unlock_notification_title">ロック解除が必要</string>

View file

@ -1020,6 +1020,7 @@
<string name="user_wallet_list_rename_popup_title">Переименование кошелька</string>
<string name="user_wallet_list_unlock_all">Разблокировать все</string>
<string name="user_wallet_list_unlock_all_with">Разблокировать все с %s</string>
<string name="visa_onboarding_pin_not_accepted">ПИН не принят. Попробуйте ещё раз или введите другой код.</string>
<string name="wallet_balance_blockchain_unreachable_try_later">Блокчейн недоступен. Попробуйте позже.</string>
<string name="wallet_balance_missing_derivation">Отсканируйте карту или кольцо</string>
<string name="wallet_been_activated_message">Этот кошелек уже был активирован ранее.\nЕсли это сделали не вы, свяжитесь со службой поддержки.\nTangem никогда не продает кошелек вместе с предустановленным кодом доступа.</string>

View file

@ -110,13 +110,14 @@
<string name="common_claim_rewards">Claim rewards</string>
<string name="common_close">Close</string>
<string name="common_confirm">Confirm</string>
<string name="common_contact_tangem_support">Contact Tangem Support</string>
<string name="common_contact_visa_support">Contact Visa Support</string>
<string name="common_continue">Continue</string>
<string name="common_copy">Copy</string>
<string name="common_copy_address">Copy address</string>
<string name="common_create">Create</string>
<string name="common_crypto_fiat_format">%1$s (%2$s)</string>
<string name="common_custom">Custom</string>
<string name="common_nft">NFT</string>
<plurals name="common_days">
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>
@ -157,6 +158,7 @@
<string name="common_network_fee_title">Network fee</string>
<string name="common_network_fee_warning_content">Amount sent will be reduced by %1$s (%2$s) to cover the selected fee level</string>
<string name="common_next">Next</string>
<string name="common_nft">NFT</string>
<string name="common_no">No</string>
<string name="common_no_address">No address</string>
<string name="common_now">Now</string>
@ -512,24 +514,25 @@
<string name="nft_collections_title">NFT collections</string>
<string name="nft_collections_warning_subtitle">Some data may not load</string>
<string name="nft_collections_warning_title">Temporary loading problems</string>
<string name="nft_details_base_information">Base information</string>
<string name="nft_details_chain">Chain</string>
<string name="nft_details_contract_address">Contract Address</string>
<string name="nft_details_last_sale_price">Last sale price</string>
<string name="nft_details_rarity_label">Rarity label</string>
<string name="nft_details_rarity_rank">Rarity rank</string>
<string name="nft_details_token_address">Token Address</string>
<string name="nft_details_token_id">Token ID</string>
<string name="nft_details_token_standard">Token Standard</string>
<string name="nft_details_traits">Traits</string>
<string name="nft_empty_search">No results. Please try another request.</string>
<string name="nft_receive_title">Receive NFT</string>
<string name="nft_receive_choose_network">Choose network</string>
<string name="nft_receive_subtitle">To My wallet</string>
<string name="nft_receive_title">Receive NFT</string>
<string name="nft_traits_title">Traits</string>
<string name="nft_wallet_count">%1$d NFTs in %2$d collection</string>
<string name="nft_wallet_receive_nft">Tap here to receive first NFT</string>
<string name="nft_wallet_title">NFT collections</string>
<string name="nft_wallet_unable_to_load">Unable to load the data</string>
<string name="nft_receive_choose_network">Choose network</string>
<string name="nft_details_last_sale_price">Last sale price</string>
<string name="nft_details_rarity_label">Rarity label</string>
<string name="nft_details_rarity_rank">Rarity rank</string>
<string name="nft_details_traits">Traits</string>
<string name="nft_details_base_information">Base information</string>
<string name="nft_details_token_standard">Token Standard</string>
<string name="nft_details_contract_address">Contract Address</string>
<string name="nft_details_token_id">Token ID</string>
<string name="nft_details_token_address">Token Address</string>
<string name="nft_details_chain">Chain</string>
<string name="onboarding_access_code_feature_1_description">Set up a single access code to protect all your devices.</string>
<string name="onboarding_access_code_feature_1_title">Protect</string>
<string name="onboarding_access_code_feature_2_description">Set an individual access code for each card or ring later.</string>
@ -1067,6 +1070,7 @@
<string name="visa_onboarding_pin_code_description">Set up a 4-digit code. It will be used for payments.</string>
<string name="visa_onboarding_pin_code_navigation_title">PIN code</string>
<string name="visa_onboarding_pin_code_title">Create PIN Code</string>
<string name="visa_onboarding_pin_not_accepted">PIN was not accepted. Try again or use a different code.</string>
<string name="visa_onboarding_pin_validation_error_message">Invalid PIN: avoid sequences or repeats</string>
<string name="visa_onboarding_success_screen_description">You\'re good to go!</string>
<string name="visa_onboarding_tangem_approve_description">Prepare the Tangem card and tap to approve</string>
@ -1098,6 +1102,7 @@
<string name="visa_transaction_details_transaction_request">Transaction request</string>
<string name="visa_transaction_details_transaction_status">Transaction status</string>
<string name="visa_transaction_details_type">Type</string>
<string name="visa_tx_dispute_button">Dispute this transaction</string>
<string name="visa_unlock_notification_button">Unlock</string>
<string name="visa_unlock_notification_subtitle">Scan your card to unlock access</string>
<string name="visa_unlock_notification_title">Needed unlock</string>
@ -1231,6 +1236,21 @@
<string name="warning_testnet_card_message">This is a Testnet card. It cannot process transactions and should only be used for testing and development purposes.</string>
<string name="warning_testnet_card_title">For testing purposes only</string>
<string name="warning_token_balance_not_updated">Balance may be outdated. Refresh the page.</string>
<string name="wc_alert_audit_unknown_domain">Unknown domain</string>
<string name="wc_alert_connect_anyway">Connect anyway</string>
<string name="wc_alert_connection_timeout_description">Timeout error. Please, try again later.</string>
<string name="wc_alert_connection_timeout_title">Failed to establish WalletConnect</string>
<string name="wc_alert_domain_issues_description">This domain cannot be verified. Check the request carefully approving.</string>
<string name="wc_alert_session_disconnected_description">Go back to your browser and connect via WalletConnect again.</string>
<string name="wc_alert_session_disconnected_title">WalletConnect session was disconnected</string>
<string name="wc_alert_unknown_error_description">Error code: %s. If the problem persists — feel free to contact our support.</string>
<string name="wc_alert_unknown_error_title">We\'ve encountered unknown error</string>
<string name="wc_alert_unsupported_networks_description">Tangem does not currently support a required network by %s.</string>
<string name="wc_alert_unsupported_networks_title">Unsuported networks</string>
<string name="wc_alert_verified_domain_description">Tangem support a required network by %s.</string>
<string name="wc_alert_verified_domain_title">Verified domain</string>
<string name="wc_alert_wrong_card_description">Wrong card or ring selected in Tangem App</string>
<string name="wc_alert_wrong_card_title">We\'ve got some kind of problem</string>
<string name="wc_connections">Connections</string>
<string name="wc_disconnect_all">Disconnect all</string>
<string name="wc_disconnect_all_alert_desc">Text about discnected all dApps</string>

View file

@ -0,0 +1,17 @@
package com.tangem.core.ui.components.artwork
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Immutable
data class ArtworkUM(
val verifiedArtwork: ImmutableList<Byte>? = null,
val defaultUrl: String,
) {
constructor(bytes: ByteArray?, defaultUrl: String) : this(
verifiedArtwork = bytes?.toList()?.toImmutableList(),
defaultUrl = defaultUrl,
)
}

View file

@ -0,0 +1,485 @@
package com.tangem.core.ui.components.atoms.text
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
/**
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
private const val READ_MORE_TAG = "read_more"
private const val READ_LESS_TAG = "read_less"
/**
* Basic element that displays text with read more.
*
* @param text The text to be displayed.
* @param expanded whether this text is expanded or collapsed.
* @param modifier [Modifier] to apply to this layout node.
* @param onExpandRequested called when this text is clicked. If `null`, then this text will not be
* interactable, unless something else handles its input events and updates its state.
* @param contentPadding a padding around the text.
* @param style Style configuration for the text such as color, font, line height etc.
* @param onTextLayout Callback that is executed when a new text layout is calculated. A
* [TextLayoutResult] object that callback provides contains paragraph information, size of the
* text, baselines and other details. The callback can be used to add additional decoration or
* functionality to the text. For example, to draw selection around the text.
* @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the
* text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
* [readMoreOverflow] and TextAlign may have unexpected effects.
* @param readMoreText The read more text to be displayed in the collapsed state.
* @param readMoreMaxLines An optional maximum number of lines for the text to span, wrapping if
* necessary. If the text exceeds the given number of lines, it will be truncated according to
* [readMoreOverflow]. If it is not null, then it must be greater than zero.
* @param readMoreOverflow How visual overflow should be handled in the collapsed state.
* @param readMoreStyle Style configuration for the read more text such as color, font, line height
* etc.
* @param readLessText The read less text to be displayed in the expanded state.
* @param readLessStyle Style configuration for the read less text such as color, font, line height
* etc.
* @param toggleArea A clickable area of text to toggle.
*/
@Composable
fun ReadMoreText(
text: String,
expanded: Boolean,
modifier: Modifier = Modifier,
onExpandRequested: ((Boolean) -> Unit)? = null,
contentPadding: PaddingValues = PaddingValues(0.dp),
style: TextStyle = TextStyle.Default,
onTextLayout: (TextLayoutResult) -> Unit = {},
softWrap: Boolean = true,
readMoreText: String = "",
readMoreMaxLines: Int = 2,
readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis,
readMoreStyle: SpanStyle = style.toSpanStyle(),
readLessText: String = "",
readLessStyle: SpanStyle = readMoreStyle,
toggleArea: ToggleArea = ToggleArea.All,
) {
ReadMoreTextInternal(
text = AnnotatedString(text),
expanded = expanded,
modifier = modifier,
onExpandRequested = onExpandRequested,
contentPadding = contentPadding,
style = style,
onTextLayout = onTextLayout,
softWrap = softWrap,
readMoreText = readMoreText,
readMoreMaxLines = readMoreMaxLines,
readMoreOverflow = readMoreOverflow,
readMoreStyle = readMoreStyle,
readLessText = readLessText,
readLessStyle = readLessStyle,
toggleArea = toggleArea,
)
}
/**
* Basic element that displays text with read more.
*
* @param text The text to be displayed.
* @param expanded whether this text is expanded or collapsed.
* @param modifier [Modifier] to apply to this layout node.
* @param onExpandRequested called when this text is clicked. If `null`, then this text will not be
* interactable, unless something else handles its input events and updates its state.
* @param contentPadding a padding around the text.
* @param style Style configuration for the text such as color, font, line height etc.
* @param onTextLayout Callback that is executed when a new text layout is calculated. A
* [TextLayoutResult] object that callback provides contains paragraph information, size of the
* text, baselines and other details. The callback can be used to add additional decoration or
* functionality to the text. For example, to draw selection around the text.
* @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the
* text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
* [readMoreOverflow] and TextAlign may have unexpected effects.
* @param readMoreText The read more text to be displayed in the collapsed state.
* @param readMoreMaxLines An optional maximum number of lines for the text to span, wrapping if
* necessary. If the text exceeds the given number of lines, it will be truncated according to
* [readMoreOverflow]. If it is not null, then it must be greater than zero.
* @param readMoreOverflow How visual overflow should be handled in the collapsed state.
* @param readMoreStyle Style configuration for the read more text such as color, font, line height
* etc.
* @param readLessText The read less text to be displayed in the expanded state.
* @param readLessStyle Style configuration for the read less text such as color, font, line height
* etc.
* @param toggleArea A clickable area of text to toggle.
*/
@Composable
fun ReadMoreText(
text: AnnotatedString,
expanded: Boolean,
modifier: Modifier = Modifier,
onExpandRequested: ((Boolean) -> Unit)? = null,
contentPadding: PaddingValues = PaddingValues(0.dp),
style: TextStyle = TextStyle.Default,
onTextLayout: (TextLayoutResult) -> Unit = {},
softWrap: Boolean = true,
readMoreText: String = "",
readMoreMaxLines: Int = 2,
readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis,
readMoreStyle: SpanStyle = style.toSpanStyle(),
readLessText: String = "",
readLessStyle: SpanStyle = readMoreStyle,
toggleArea: ToggleArea = ToggleArea.All,
) {
ReadMoreTextInternal(
text = text,
expanded = expanded,
modifier = modifier,
onExpandRequested = onExpandRequested,
contentPadding = contentPadding,
style = style,
onTextLayout = onTextLayout,
softWrap = softWrap,
readMoreText = readMoreText,
readMoreMaxLines = readMoreMaxLines,
readMoreOverflow = readMoreOverflow,
readMoreStyle = readMoreStyle,
readLessText = readLessText,
readLessStyle = readLessStyle,
toggleArea = toggleArea,
)
}
@Suppress("LongMethod", "LongParameterList")
@Composable
private fun ReadMoreTextInternal(
text: AnnotatedString,
expanded: Boolean,
modifier: Modifier = Modifier,
onExpandRequested: ((Boolean) -> Unit)? = null,
contentPadding: PaddingValues = PaddingValues(0.dp),
style: TextStyle = TextStyle.Default,
onTextLayout: (TextLayoutResult) -> Unit = {},
softWrap: Boolean = true,
readMoreText: String = "",
readMoreMaxLines: Int = 2,
readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis,
readMoreStyle: SpanStyle = style.toSpanStyle(),
readLessText: String = "",
readLessStyle: SpanStyle = readMoreStyle,
toggleArea: ToggleArea = ToggleArea.All,
) {
require(readMoreMaxLines > 0) { "readMoreMaxLines should be greater than 0" }
val overflowText: String = remember(readMoreOverflow) {
buildString {
when (readMoreOverflow) {
ReadMoreTextOverflow.Clip -> {
}
ReadMoreTextOverflow.Ellipsis -> {
append(Typography.ellipsis)
}
}
if (readMoreText.isNotEmpty()) {
append(Typography.nbsp)
}
}
}
val readMoreTextWithStyle: AnnotatedString = remember(readMoreText, readMoreStyle) {
buildAnnotatedString {
if (readMoreText.isNotEmpty()) {
withStyle(readMoreStyle) {
append(readMoreText.replace(' ', Typography.nbsp))
}
}
}
}
val readLessTextWithStyle: AnnotatedString = remember(readLessText, readLessStyle) {
buildAnnotatedString {
if (readLessText.isNotEmpty()) {
withStyle(readLessStyle) {
append(readLessText)
}
}
}
}
val textMeasurer = rememberTextMeasurer()
val state = remember { ReadMoreState() }
val currentText = buildAnnotatedString {
if (expanded) {
append(text)
if (readLessTextWithStyle.isNotEmpty()) {
append(' ')
if (toggleArea == ToggleArea.More) {
withLink(
LinkAnnotation.Clickable(tag = READ_LESS_TAG) {
onExpandRequested?.invoke(false)
},
) {
append(readLessTextWithStyle)
}
} else {
append(readLessTextWithStyle)
}
}
} else {
val collapsedText = state.collapsedText
if (collapsedText.isNotEmpty()) {
append(collapsedText)
append(overflowText)
if (toggleArea == ToggleArea.More) {
withLink(
LinkAnnotation.Clickable(tag = READ_MORE_TAG) {
onExpandRequested?.invoke(true)
},
) {
append(readMoreTextWithStyle)
}
} else {
append(readMoreTextWithStyle)
}
} else {
append(text)
}
}
}
val toggleableModifier = if (onExpandRequested != null && toggleArea == ToggleArea.All) {
Modifier.clickable(
enabled = state.isCollapsible,
onClick = { onExpandRequested(!expanded) },
)
} else {
Modifier
}
BoxWithConstraints(
modifier = modifier
.then(toggleableModifier)
.padding(contentPadding),
) {
BasicText(
text = currentText,
modifier = Modifier,
style = style,
onTextLayout = onTextLayout,
overflow = TextOverflow.Ellipsis,
softWrap = softWrap,
maxLines = if (expanded) Int.MAX_VALUE else readMoreMaxLines,
)
val constraints = Constraints(maxWidth = constraints.maxWidth)
LaunchedEffect(
textMeasurer,
constraints,
overflowText,
readMoreTextWithStyle,
style,
readMoreStyle,
text,
readMoreMaxLines,
softWrap,
) {
state.applyCollapsedText(
textMeasurer = textMeasurer,
constraints = constraints,
overflowText = overflowText,
readMoreTextWithStyle = readMoreTextWithStyle,
style = style,
readMoreStyle = readMoreStyle,
text = text,
readMoreMaxLines = readMoreMaxLines,
softWrap = softWrap,
)
}
}
}
@Stable
private class ReadMoreState {
private var _collapsedText: AnnotatedString by mutableStateOf(AnnotatedString(""))
var collapsedText: AnnotatedString
get() = _collapsedText
internal set(value) {
if (value != _collapsedText) {
_collapsedText = value
}
}
val isCollapsible: Boolean
get() = collapsedText.isNotEmpty()
@Suppress("LongParameterList")
fun applyCollapsedText(
textMeasurer: TextMeasurer,
constraints: Constraints,
overflowText: String,
readMoreTextWithStyle: AnnotatedString,
style: TextStyle,
readMoreStyle: SpanStyle,
text: AnnotatedString,
readMoreMaxLines: Int,
softWrap: Boolean,
) {
val overflowTextWidth = if (overflowText.isNotEmpty()) {
textMeasurer.measure(
text = overflowText,
style = style,
).size.width
} else {
0
}
val readMoreTextWidth = if (readMoreTextWithStyle.isNotEmpty()) {
textMeasurer.measure(
text = readMoreTextWithStyle,
style = style.merge(readMoreStyle),
).size.width
} else {
0
}
val textLayout = textMeasurer.measure(
text = text,
style = style,
maxLines = readMoreMaxLines,
overflow = TextOverflow.Clip,
softWrap = softWrap,
constraints = constraints,
)
val clipTextCount = textLayout.getLineEnd(lineIndex = textLayout.lineCount - 1)
val isLineClipped = text.count() > clipTextCount
if (isLineClipped) {
val countUntilMaxLine =
textLayout.getLineEnd(readMoreMaxLines - 1, visibleEnd = true)
val decorationWidth = overflowTextWidth + readMoreTextWidth
val replaceCount = text
.substringOf(textLayout, line = readMoreMaxLines)
.calculateReplaceCountToBeSingleLineWith(
maximumTextWidth = constraints.maxWidth - decorationWidth,
measureTextWidth = { subText ->
textMeasurer.measure(
text = subText,
style = style,
softWrap = softWrap,
).size.width
},
)
collapsedText = text.subSequence(0, countUntilMaxLine - replaceCount)
} else {
collapsedText = AnnotatedString("")
}
}
private fun AnnotatedString.substringOf(layout: TextLayoutResult, line: Int): AnnotatedString {
val lastLineStartIndex = layout.getLineStart(line - 1)
val lastLineEndIndex = layout.getLineEnd(line - 1, visibleEnd = true)
return subSequence(lastLineStartIndex, lastLineEndIndex)
}
private inline fun AnnotatedString.calculateReplaceCountToBeSingleLineWith(
maximumTextWidth: Int,
measureTextWidth: (subText: AnnotatedString) -> Int,
): Int {
var replacedTextWidth: Int
var replacedCount = -1
do {
replacedCount++
replacedTextWidth = measureTextWidth(
subSequence(0, this.length - replacedCount),
)
} while (replacedCount < this.length && replacedTextWidth >= maximumTextWidth)
val lastVisibleChar: Char? = this.getOrNull(this.length - replacedCount - 1)
val firstOverflowChar: Char? = this.getOrNull(this.length - replacedCount)
if (lastVisibleChar?.isSurrogate() == true && firstOverflowChar?.isHighSurrogate() == false) {
val subText = subSequence(0, this.length - replacedCount)
if (subText.isNotEmpty()) {
return length - subText.indexOfLast { it.isHighSurrogate() }
}
}
return replacedCount
}
}
@JvmInline
value class ToggleArea private constructor(internal val value: Int) {
override fun toString(): String {
return when (this) {
All -> "All"
More -> "More"
else -> "Invalid"
}
}
companion object {
/**
* All area of the text is clickable to toggle.
*/
@Stable
val All: ToggleArea = ToggleArea(1)
/**
* 'More' and 'Less' area of the text is clickable to toggle.
*/
@Stable
val More: ToggleArea = ToggleArea(2)
}
}
@JvmInline
value class ReadMoreTextOverflow private constructor(internal val value: Int) {
override fun toString(): String {
return when (this) {
Clip -> "Clip"
Ellipsis -> "Ellipsis"
else -> "Invalid"
}
}
companion object {
/**
* Clip the overflowing text to fix its container.
*/
@Stable
val Clip: ReadMoreTextOverflow = ReadMoreTextOverflow(1)
/**
* Use an ellipsis to indicate that the text has overflowed.
*/
@Stable
val Ellipsis: ReadMoreTextOverflow = ReadMoreTextOverflow(2)
}
}

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.res.TangemTheme
@ -19,12 +20,13 @@ fun CurrencyIconTopBadge(
alpha: Float,
colorFilter: ColorFilter?,
modifier: Modifier = Modifier,
background: Color = TangemTheme.colors.background.primary,
) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size18)
.background(
color = TangemTheme.colors.background.primary,
color = background,
shape = CircleShape,
),
) {

View file

@ -95,6 +95,7 @@ data class TangemDimens internal constructor(
val size142: Dp = 142.dp,
val size158: Dp = 158.dp,
val size164: Dp = 164.dp,
val size180: Dp = 180.dp,
val size200: Dp = 200.dp,
val size248: Dp = 248.dp,
val size350: Dp = 350.dp,

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#0099FF"
android:fillType="evenOdd"
android:pathData="M6.532,6.791C6.532,5.272 7.763,4.041 9.282,4.041C10.801,4.041 12.032,5.272 12.032,6.791C12.032,7.766 11.525,8.622 10.76,9.111L11.04,9.85C11.292,9.773 11.559,9.732 11.836,9.732C12.655,9.732 13.39,10.09 13.894,10.658L14.119,10.418C13.92,10.038 13.808,9.605 13.808,9.146C13.808,7.627 15.039,6.396 16.558,6.396C18.076,6.396 19.308,7.627 19.308,9.146C19.308,10.664 18.076,11.896 16.558,11.896C16.044,11.896 15.563,11.755 15.152,11.509L14.564,12.135C14.579,12.249 14.586,12.365 14.586,12.482C14.586,13.038 14.421,13.556 14.137,13.989L16.034,15.229C16.533,14.724 17.226,14.41 17.992,14.41C19.511,14.41 20.742,15.641 20.742,17.16C20.742,18.679 19.511,19.91 17.992,19.91C16.473,19.91 15.242,18.679 15.242,17.16C15.242,16.95 15.266,16.745 15.311,16.548L12.943,15C12.605,15.149 12.23,15.232 11.836,15.232C11.269,15.232 10.742,15.061 10.304,14.766L8.465,16.455C8.603,16.783 8.68,17.144 8.68,17.522C8.68,19.041 7.448,20.272 5.93,20.272C4.411,20.272 3.18,19.041 3.18,17.522C3.18,16.004 4.411,14.773 5.93,14.773C6.524,14.773 7.074,14.961 7.524,15.282L9.33,13.624L9.337,13.632C9.176,13.283 9.086,12.893 9.086,12.482C9.086,11.796 9.337,11.169 9.753,10.687L9.319,9.541L9.282,9.541C7.763,9.541 6.532,8.31 6.532,6.791ZM9.282,5.541C8.592,5.541 8.032,6.101 8.032,6.791C8.032,7.481 8.592,8.041 9.282,8.041C9.973,8.041 10.532,7.481 10.532,6.791C10.532,6.101 9.973,5.541 9.282,5.541ZM16.558,7.896C15.867,7.896 15.308,8.455 15.308,9.146C15.308,9.836 15.867,10.396 16.558,10.396C17.248,10.396 17.808,9.836 17.808,9.146C17.808,8.455 17.248,7.896 16.558,7.896ZM11.836,11.232C11.146,11.232 10.586,11.792 10.586,12.482C10.586,13.173 11.146,13.732 11.836,13.732C12.526,13.732 13.086,13.173 13.086,12.482C13.086,11.792 12.526,11.232 11.836,11.232ZM4.68,17.522C4.68,16.832 5.239,16.272 5.93,16.272C6.62,16.272 7.18,16.832 7.18,17.522C7.18,18.213 6.62,18.772 5.93,18.772C5.239,18.772 4.68,18.213 4.68,17.522ZM16.742,17.16C16.742,16.47 17.302,15.91 17.992,15.91C18.683,15.91 19.242,16.47 19.242,17.16C19.242,17.851 18.683,18.41 17.992,18.41C17.302,18.41 16.742,17.851 16.742,17.16Z" />
</vector>

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:autoMirrored="true"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:fillColor="#0099FF"
android:fillType="evenOdd"
android:pathData="M8.807,1.939C9.142,1.683 9.524,1.482 9.999,1.482C10.473,1.482 10.855,1.683 11.191,1.939C11.501,2.176 11.842,2.517 12.231,2.906L12.263,2.938C12.646,3.32 12.967,3.464 13.474,3.464C13.536,3.464 13.617,3.461 13.711,3.459C13.958,3.452 14.291,3.442 14.576,3.466C14.993,3.502 15.537,3.615 15.956,4.03C16.377,4.448 16.494,4.995 16.53,5.415C16.555,5.702 16.545,6.037 16.538,6.286C16.536,6.38 16.534,6.461 16.534,6.523C16.534,6.867 16.572,7.046 16.631,7.181C16.692,7.32 16.803,7.477 17.06,7.734L17.091,7.766C17.481,8.155 17.821,8.496 18.059,8.807C18.315,9.142 18.515,9.524 18.515,9.998C18.515,10.473 18.315,10.855 18.059,11.19C17.821,11.501 17.481,11.842 17.091,12.231L17.06,12.263C16.803,12.52 16.692,12.677 16.631,12.816C16.572,12.951 16.534,13.13 16.534,13.474C16.534,13.536 16.536,13.617 16.538,13.711C16.545,13.96 16.555,14.295 16.53,14.582C16.494,15.002 16.377,15.549 15.956,15.967C15.537,16.382 14.993,16.495 14.576,16.531C14.291,16.555 13.958,16.545 13.711,16.538C13.618,16.535 13.536,16.533 13.474,16.533C13.136,16.533 12.96,16.567 12.828,16.622C12.696,16.676 12.545,16.777 12.305,17.017C12.258,17.065 12.192,17.135 12.115,17.217C11.934,17.411 11.689,17.674 11.467,17.868C11.13,18.163 10.628,18.515 9.999,18.515C9.37,18.515 8.868,18.163 8.531,17.868C8.308,17.674 8.063,17.411 7.882,17.217C7.805,17.135 7.74,17.065 7.692,17.017C7.452,16.777 7.302,16.676 7.17,16.622C7.038,16.567 6.861,16.533 6.524,16.533C6.462,16.533 6.38,16.535 6.287,16.538C6.039,16.545 5.707,16.555 5.422,16.531C5.004,16.495 4.46,16.382 4.042,15.967C3.62,15.549 3.504,15.002 3.468,14.582C3.443,14.295 3.452,13.96 3.459,13.711C3.462,13.617 3.464,13.536 3.464,13.474C3.464,13.13 3.426,12.951 3.367,12.816C3.306,12.677 3.195,12.52 2.938,12.263L2.906,12.231C2.517,11.842 2.176,11.501 1.939,11.19C1.683,10.855 1.482,10.473 1.482,9.998C1.482,9.524 1.683,9.142 1.939,8.807C2.176,8.496 2.517,8.155 2.906,7.766L2.938,7.734C3.321,7.351 3.464,7.03 3.464,6.523C3.464,6.461 3.462,6.38 3.459,6.286C3.452,6.039 3.443,5.706 3.467,5.421C3.502,5.004 3.615,4.46 4.03,4.041C4.448,3.62 4.995,3.504 5.415,3.467C5.703,3.442 6.038,3.452 6.287,3.459C6.38,3.461 6.461,3.464 6.524,3.464C7.03,3.464 7.352,3.32 7.734,2.938L7.766,2.906C8.155,2.517 8.496,2.176 8.807,1.939Z" />
<path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M13.464,8.04C13.821,7.854 13.96,7.414 13.774,7.057C13.587,6.7 13.147,6.561 12.79,6.747C11.373,7.485 10.195,8.914 9.399,10.075C9.087,10.531 8.823,10.96 8.616,11.317C8.408,11.119 8.2,10.95 8.016,10.811C7.8,10.649 7.606,10.521 7.465,10.432C7.394,10.388 7.336,10.354 7.294,10.33C7.273,10.318 7.257,10.308 7.244,10.301L7.23,10.293L7.225,10.29L7.223,10.289L7.222,10.289C6.868,10.097 6.425,10.228 6.233,10.581C6.041,10.935 6.172,11.378 6.526,11.57L6.532,11.573C6.538,11.577 6.549,11.583 6.563,11.592C6.592,11.608 6.637,11.635 6.693,11.67C6.806,11.74 6.964,11.844 7.139,11.976C7.499,12.248 7.888,12.603 8.132,12.992C8.273,13.217 8.526,13.347 8.791,13.332C9.056,13.317 9.292,13.159 9.407,12.92L9.407,12.919L9.411,12.913L9.425,12.884C9.438,12.858 9.457,12.818 9.484,12.767C9.537,12.664 9.616,12.513 9.719,12.328C9.926,11.956 10.227,11.447 10.602,10.9C11.369,9.781 12.379,8.605 13.464,8.04Z" />
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:autoMirrored="true"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:fillColor="#FFB71B"
android:fillType="evenOdd"
android:pathData="M16.214,1.668C14.03,1.668 12.267,2.448 10.774,3.263C10.303,3.52 9.876,3.769 9.474,4.002C8.525,4.554 7.719,5.023 6.819,5.326C6.683,5.371 6.542,5.416 6.399,5.461C5.631,5.703 4.787,5.969 4.269,6.575C3.799,7.126 3.677,7.846 3.56,8.537L3.543,8.634C2.104,17.057 5.406,26.376 13.794,29.764C14.591,30.086 15.207,30.335 16.218,30.335C17.23,30.335 17.846,30.086 18.643,29.764C27.031,26.376 30.331,17.065 28.889,8.633L28.873,8.537C28.755,7.845 28.633,7.125 28.163,6.575C27.646,5.968 26.801,5.703 26.034,5.461L26.033,5.461C25.889,5.416 25.749,5.371 25.614,5.326C24.713,5.023 23.906,4.554 22.956,4.002C22.554,3.768 22.127,3.52 21.656,3.263C20.163,2.448 18.399,1.668 16.214,1.668ZM14.884,21.634C14.884,20.897 15.48,20.3 16.217,20.3H16.229C16.965,20.3 17.562,20.897 17.562,21.634C17.562,22.37 16.965,22.967 16.229,22.967H16.217C15.48,22.967 14.884,22.37 14.884,21.634ZM15.217,17.634C15.217,18.186 15.665,18.634 16.217,18.634C16.769,18.634 17.217,18.186 17.217,17.634V9.634C17.217,9.081 16.769,8.634 16.217,8.634C15.665,8.634 15.217,9.081 15.217,9.634V17.634Z" />
</vector>

View file

@ -24,7 +24,10 @@ class JobHolder {
/** Cancel current [job] */
fun cancel() {
job?.cancel()
job = null
}
fun isEmpty() = job == null
}
fun Job.saveIn(jobHolder: JobHolder): Job = jobHolder.update(job = this)

View file

@ -1,6 +1,7 @@
package com.tangem.data.feedback.converters
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.common.TapWorkarounds.isVisa
import com.tangem.domain.common.util.getBackupCardsCount
import com.tangem.domain.feedback.models.CardInfo
import com.tangem.domain.models.scan.CardDTO
@ -29,6 +30,7 @@ internal object CardInfoConverter : Converter<ScanResponse, CardInfo> {
},
isImported = value.card.wallets.any(CardDTO.Wallet::isImported),
isStart2Coin = value.card.isStart2Coin,
isVisa = value.card.isVisa,
)
}
}

View file

@ -65,7 +65,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// check after producer.produce()
verify { networksStatusesStore.get(params.userWalletId) }
val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -95,7 +95,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet())
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values1 = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -112,7 +112,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
networksStatusesFlow.emit(updatedStatuses.map(NetworkStatus::toSimple).toSet())
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values2 = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -143,7 +143,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet())
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values1 = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -155,7 +155,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// second emit
networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet())
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values2 = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -191,7 +191,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// check after producer.produce()
verify { networksStatusesStore.get(params.userWalletId) }
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values1 = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -202,7 +202,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
innerFlow.emit(value = true)
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values2 = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.
@ -222,7 +222,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// check after producer.produce()
verify { networksStatusesStore.get(params.userWalletId) }
val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values = getEmittedValues(flow = actual)
verify(inverse = true) { userWalletsStore.getSyncOrNull(params.userWalletId) }
@ -248,7 +248,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// check after producer.produce()
verify { networksStatusesStore.get(params.userWalletId) }
val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values = getEmittedValues(flow = actual)
// Check after flow was observed by subscriber (getEmittedValues).
// Otherwise, userWalletsStore.getSyncOrNull is not called.

View file

@ -53,7 +53,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
verify { multiNetworkStatusSupplier(multiParams) }
val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(status))
@ -74,7 +74,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
val status = MockNetworkStatusFactory.createMissedDerivation(params.network)
expected.emit(value = setOf(status))
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
@ -83,7 +83,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
val updatedStatus = status.copy(value = NetworkStatus.Unreachable(null))
expected.emit(value = setOf(updatedStatus))
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus))
@ -104,7 +104,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
val status = MockNetworkStatusFactory.createMissedDerivation(params.network)
expected.emit(value = setOf(status))
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
@ -112,7 +112,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
// second emit
expected.emit(value = setOf(status))
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
@ -140,7 +140,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
verify { multiNetworkStatusSupplier(multiParams) }
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
val fallbackStatus = MockNetworkStatusFactory.createUnreachable(params.network)
@ -148,7 +148,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
innerFlow.emit(value = true)
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
}
@ -166,7 +166,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
verify { multiNetworkStatusSupplier(multiParams) }
val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(0)
}

View file

@ -30,7 +30,7 @@ internal class NetworksStatusesStoreGetMethodTest {
fun `test get if runtime store is empty`() = runTest {
val actual = store.get(userWalletId = userWalletId)
val values = backgroundScope.getEmittedValues(testScheduler, actual)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values).isEqualTo(emptyList<Set<SimpleNetworkStatus>>())
}
@ -41,7 +41,7 @@ internal class NetworksStatusesStoreGetMethodTest {
val actual = store.get(userWalletId = userWalletId)
val values = backgroundScope.getEmittedValues(testScheduler, actual)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values).isEqualTo(emptyList<Set<SimpleNetworkStatus>>())
}
@ -54,7 +54,7 @@ internal class NetworksStatusesStoreGetMethodTest {
val actual = store.get(userWalletId = userWalletId)
val values = backgroundScope.getEmittedValues(testScheduler, actual)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(emptySet<SimpleNetworkStatus>()))
@ -70,7 +70,7 @@ internal class NetworksStatusesStoreGetMethodTest {
val actual = store.get(userWalletId = userWalletId)
val values = backgroundScope.getEmittedValues(testScheduler, actual)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(setOf(status.toSimple())))

View file

@ -31,7 +31,7 @@ internal class NetworksStatusesStoreInitializationTest {
DefaultNetworksStatusesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore, // local mock
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)

1
data/quotes/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,36 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.quotes"
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.data.common)
implementation(projects.data.tokens)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.quotes)
implementation(projects.domain.wallets.models)
implementation(deps.androidx.datastore)
implementation(deps.timber)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.quotes
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.domain.quotes.QuotesRepositoryV2
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import javax.inject.Inject
/**
* Default implementation of [QuotesRepositoryV2]
*
* @property quotesStore quotes store
*
[REDACTED_AUTHOR]
*/
internal class DefaultQuotesRepositoryV2 @Inject constructor(
private val quotesStore: QuotesStoreV2,
) : QuotesRepositoryV2 {
override suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set<CryptoCurrency.RawID>): Set<Quote>? {
return quotesStore.getAllSyncOrNull()?.mapTo(hashSetOf()) {
it.takeIf { it.rawCurrencyId in currenciesIds } ?: Quote.Empty(it.rawCurrencyId)
}
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.data.quotes.di
import com.tangem.data.quotes.multi.DefaultMultiQuoteFetcher
import com.tangem.data.quotes.multi.DefaultMultiQuoteUpdater
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface QuoteFetcherModule {
@Binds
@Singleton
fun bindMultiQuoteFetcher(impl: DefaultMultiQuoteFetcher): MultiQuoteFetcher
@Binds
@Singleton
fun bindMultiQuoteUpdater(impl: DefaultMultiQuoteUpdater): MultiQuoteUpdater
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.quotes.di
import com.tangem.data.quotes.single.DefaultSingleQuoteProducer
import com.tangem.domain.quotes.single.SingleQuoteProducer
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface QuoteProducerFactoryModule {
@Binds
@Singleton
fun bindSingleQuoteProducerFactory(impl: DefaultSingleQuoteProducer.Factory): SingleQuoteProducer.Factory
}

View file

@ -0,0 +1,42 @@
package com.tangem.data.quotes.di
import androidx.datastore.core.DataStore
import com.tangem.data.quotes.store.CurrencyIdWithQuote
import com.tangem.data.quotes.store.DefaultQuotesStoreV2
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.quotes.single.SingleQuoteProducer
import com.tangem.domain.quotes.single.SingleQuoteSupplier
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
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 QuoteSupplierModule {
@Provides
@Singleton
fun provideQuotesStoreV2(
persistenceQuotesStore: DataStore<CurrencyIdWithQuote>,
dispatchers: CoroutineDispatcherProvider,
): QuotesStoreV2 {
return DefaultQuotesStoreV2(
runtimeStore = RuntimeSharedStore(),
persistenceDataStore = persistenceQuotesStore,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideSingleQuoteSupplier(factory: SingleQuoteProducer.Factory): SingleQuoteSupplier {
return object : SingleQuoteSupplier(
factory = factory,
keyCreator = { "single_quote_${it.rawCurrencyId.value}" },
) {}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.quotes.di
import com.tangem.data.quotes.DefaultQuotesRepositoryV2
import com.tangem.domain.quotes.QuotesRepositoryV2
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface QuotesDataModule {
@Binds
@Singleton
fun bindQuotesRepositoryV2(impl: DefaultQuotesRepositoryV2): QuotesRepositoryV2
}

View file

@ -0,0 +1,85 @@
package com.tangem.data.quotes.multi
import arrow.core.Either
import com.tangem.data.common.api.safeApiCallWithTimeout
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Default implementation of [MultiQuoteFetcher]
*
* @property tangemTechApi tangemTech api
* @property appCurrencyResponseStore app currency response store
* @property quotesStore quotes store
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
@Singleton
internal class DefaultMultiQuoteFetcher @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val appCurrencyResponseStore: AppCurrencyResponseStore,
private val quotesStore: QuotesStoreV2,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiQuoteFetcher {
private val quotesUnsupportedCurrenciesAdapter = QuotesUnsupportedCurrenciesIdAdapter()
override suspend fun invoke(params: MultiQuoteFetcher.Params) = Either.catchOn(dispatchers.default) {
if (params.currenciesIds.isEmpty()) {
Timber.d("No currencies to fetch quotes for")
return@catchOn
}
quotesStore.refresh(currenciesIds = params.currenciesIds)
val replacementIdsResult = quotesUnsupportedCurrenciesAdapter.replaceUnsupportedCurrencies(
currenciesIds = params.currenciesIds.mapTo(
destination = hashSetOf(),
transform = CryptoCurrency.RawID::value,
),
)
val appCurrencyId = getAppCurrencyId(params = params)
val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",")
val response = safeApiCallWithTimeout(
call = { tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds).bind() },
onError = { error -> throw error },
)
val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies(
response = response,
filteredIds = replacementIdsResult.idsFiltered,
)
quotesStore.storeActual(values = updatedResponse.quotes)
}
.onLeft {
Timber.e(it)
quotesStore.storeError(currenciesIds = params.currenciesIds)
}
private suspend fun getAppCurrencyId(params: MultiQuoteFetcher.Params): String {
val appCurrencyId = params.appCurrencyId
?: appCurrencyResponseStore.getSyncOrNull()?.id
if (appCurrencyId.isNullOrBlank()) {
val exception = IllegalStateException("Unable to get AppCurrency for updating quotes")
Timber.e(exception)
throw exception
}
return appCurrencyId
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.data.quotes.multi
import androidx.annotation.VisibleForTesting
import arrow.core.left
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.core.utils.EitherFlow
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Default implementation of [MultiQuoteUpdater] which updates quotes when the app currency changes
*
* @property appCurrencyResponseStore app currency response store
* @property quotesStore quotes store
* @property multiQuoteFetcher multi quote fetcher
* @param dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
@Singleton
internal class DefaultMultiQuoteUpdater @Inject constructor(
private val appCurrencyResponseStore: AppCurrencyResponseStore,
private val quotesStore: QuotesStoreV2,
private val multiQuoteFetcher: MultiQuoteFetcher,
dispatchers: CoroutineDispatcherProvider,
) : MultiQuoteUpdater {
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val updaterHolder = JobHolder()
override fun subscribe() {
Timber.d("Subscribe on quotes updates")
getMultiQuoteUpdates()
.launchIn(coroutineScope)
.saveIn(updaterHolder)
}
override fun unsubscribe() {
Timber.e("Unsubscribe from quotes updates")
updaterHolder.cancel()
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun getMultiQuoteUpdates(): EitherFlow<Throwable, Unit> {
return appCurrencyResponseStore.get()
.drop(count = 1) // skip initial value
.distinctUntilChanged()
.filterNotNull()
.mapLatest { appCurrency ->
val currenciesIds = quotesStore.getAllSyncOrNull().orEmpty()
.mapTo(destination = hashSetOf(), transform = Quote::rawCurrencyId)
multiQuoteFetcher(
params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrency.id),
)
.onLeft(Timber::e)
}
.retryWhen { cause, _ ->
Timber.e("Retry updating quotes: $cause")
emit(cause.left())
delay(timeMillis = 2000)
true
}
}
@VisibleForTesting(VisibleForTesting.NONE)
fun getMultiQuoteUpdatesFlow(): EitherFlow<Throwable, Unit> = getMultiQuoteUpdates()
@VisibleForTesting(VisibleForTesting.NONE)
fun getUpdaterJobHolder(): JobHolder = updaterHolder
}

View file

@ -0,0 +1,40 @@
package com.tangem.data.quotes.single
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.domain.quotes.single.SingleQuoteProducer
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapNotNull
/**
* Default implementation of [SingleQuoteProducer]
*
* @property params params
* @property quotesStore quotes store
*/
internal class DefaultSingleQuoteProducer @AssistedInject constructor(
@Assisted val params: SingleQuoteProducer.Params,
private val quotesStore: QuotesStoreV2,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleQuoteProducer {
override val fallback: Quote = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
override fun produce(): Flow<Quote> {
return quotesStore.get()
.mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } }
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : SingleQuoteProducer.Factory {
override fun create(params: SingleQuoteProducer.Params): DefaultSingleQuoteProducer
}
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.quotes.store
import androidx.datastore.core.DataStore
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.quote.converter.QuoteConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
internal typealias CurrencyIdWithQuote = Map<String, QuotesResponse.Quote>
/**
* Default implementation of [QuotesStoreV2]
*
* @property runtimeStore runtime store
* @property persistenceDataStore persistence store
* @param dispatchers dispatchers
*/
internal class DefaultQuotesStoreV2(
private val runtimeStore: RuntimeSharedStore<Set<Quote>>,
private val persistenceDataStore: DataStore<CurrencyIdWithQuote>,
dispatchers: CoroutineDispatcherProvider,
) : QuotesStoreV2 {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
val cachedStatuses = persistenceDataStore.data.firstOrNull()
if (cachedStatuses.isNullOrEmpty()) return@launch
runtimeStore.store(
value = QuoteConverter(isCached = true).convertSet(input = cachedStatuses.entries),
)
}
}
override fun get(): Flow<Set<Quote>> = runtimeStore.get()
override suspend fun getAllSyncOrNull(): Set<Quote>? = runtimeStore.getSyncOrNull()
override suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>) {
updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.CACHE)
}
override suspend fun storeActual(values: Map<String, QuotesResponse.Quote>) {
coroutineScope {
launch {
val quotes = QuoteConverter(isCached = false).convertSet(input = values.entries)
storeInRuntimeStore(values = quotes)
}
launch { storeInPersistenceStore(values = values) }
}
}
override suspend fun storeError(currenciesIds: Set<CryptoCurrency.RawID>) {
updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.ONLY_CACHE)
}
private suspend fun updateStatusSourceInRuntime(currenciesIds: Set<CryptoCurrency.RawID>, source: StatusSource) {
runtimeStore.update(default = emptySet()) { stored ->
val updatedQuotes = currenciesIds.mapTo(hashSetOf()) { id ->
val quote = stored.firstOrNull { it.rawCurrencyId == id } ?: Quote.Empty(id)
quote.copySealed(source = source)
}
stored.addOrReplace(items = updatedQuotes) { old, new -> old.rawCurrencyId == new.rawCurrencyId }
}
}
private suspend fun storeInRuntimeStore(values: Set<Quote>) {
runtimeStore.update(default = emptySet()) { saved ->
saved.addOrReplace(items = values) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId }
}
}
private suspend fun storeInPersistenceStore(values: Map<String, QuotesResponse.Quote>) {
persistenceDataStore.updateData { storedQuotes -> storedQuotes + values }
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.quotes.store
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import kotlinx.coroutines.flow.Flow
/** Store of [Quote]'es set */
internal interface QuotesStoreV2 {
/** Get flow of quotes */
fun get(): Flow<Set<Quote>>
/** Get all quotes synchronously or null */
suspend fun getAllSyncOrNull(): Set<Quote>?
/** Refresh status of [currenciesIds] */
suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>)
/** Store actual map of currency ids and quotes [values] */
suspend fun storeActual(values: Map<String, QuotesResponse.Quote>)
/** Store error for [currenciesIds] */
suspend fun storeError(currenciesIds: Set<CryptoCurrency.RawID>)
}

View file

@ -0,0 +1,214 @@
package com.tangem.data.quotes.multi
import com.google.common.truth.Truth
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class DefaultMultiQuoteFetcherTest {
private val tangemTechApi = mockk<TangemTechApi>(relaxed = true)
private val appCurrencyResponseStore = mockk<AppCurrencyResponseStore>(relaxed = true)
private val quotesStore = mockk<QuotesStoreV2>(relaxed = true)
private val fetcher = DefaultMultiQuoteFetcher(
tangemTechApi = tangemTechApi,
appCurrencyResponseStore = appCurrencyResponseStore,
quotesStore = quotesStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `fetch quotes successfully`() = runTest {
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
val coinIds = "BTC,ETH"
coEvery {
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
} returns ApiResponse.Success(successResponse)
val actual = fetcher(params)
coVerifyOrder {
quotesStore.refresh(currenciesIds = params.currenciesIds)
appCurrencyResponseStore.getSyncOrNull()
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
quotesStore.storeActual(values = successResponse.quotes)
}
coVerify(inverse = true) {
quotesStore.storeError(currenciesIds = any())
}
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch quotes successfully if currenciesIds from params is empty`() = runTest {
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = null)
val actual = fetcher(params)
coVerify(inverse = true) {
quotesStore.refresh(currenciesIds = any())
appCurrencyResponseStore.getSyncOrNull()
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
quotesStore.storeActual(values = any())
quotesStore.storeError(currenciesIds = any())
}
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch quotes successfully if appCurrencyId from params is not null`() = runTest {
val appCurrencyId = "usd"
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
val coinIds = "BTC,ETH"
coEvery {
tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds)
} returns ApiResponse.Success(successResponse)
val actual = fetcher(params)
coVerifyOrder {
quotesStore.refresh(currenciesIds = params.currenciesIds)
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
quotesStore.storeActual(values = successResponse.quotes)
}
coVerify(inverse = true) {
appCurrencyResponseStore.getSyncOrNull()
quotesStore.storeError(currenciesIds = any())
}
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch quotes failure because appCurrencyId from params is blank`() = runTest {
val appCurrencyId = ""
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
val actual = fetcher(params)
coVerifyOrder {
quotesStore.refresh(currenciesIds = params.currenciesIds)
quotesStore.storeError(currenciesIds = params.currenciesIds)
}
coVerify(inverse = true) {
appCurrencyResponseStore.getSyncOrNull()
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
quotesStore.storeActual(values = any())
}
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(IllegalStateException::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat()
.isEqualTo("Unable to get AppCurrency for updating quotes")
}
@Test
fun `fetch quotes failure because api request failed`() = runTest {
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
val coinIds = "BTC,ETH"
@Suppress("UNCHECKED_CAST")
val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse<QuotesResponse>
coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse
val actual = fetcher(params)
coVerifyOrder {
quotesStore.refresh(currenciesIds = params.currenciesIds)
appCurrencyResponseStore.getSyncOrNull()
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
quotesStore.storeError(currenciesIds = params.currenciesIds)
}
coVerify(inverse = true) {
quotesStore.storeActual(values = any())
}
Truth.assertThat(actual.isLeft()).isTrue()
}
@Test
fun `fetch quotes failure because app currency not found`() = runTest {
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null
val actual = fetcher(params)
coVerifyOrder {
quotesStore.refresh(currenciesIds = params.currenciesIds)
appCurrencyResponseStore.getSyncOrNull()
quotesStore.storeError(currenciesIds = params.currenciesIds)
}
coVerify(inverse = true) {
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
quotesStore.storeActual(values = any())
}
Truth.assertThat(actual.isLeft()).isTrue()
}
private companion object {
val currenciesIds = setOf(
CryptoCurrency.RawID(value = "BTC"),
CryptoCurrency.RawID(value = "ETH"),
)
val usdAppCurrency = CurrenciesResponse.Currency(
id = "USD".lowercase(),
code = "USD",
name = "US Dollar",
unit = "$",
type = "fiat",
rateBTC = "",
)
val successResponse = QuotesResponse(
quotes = mapOf(
"BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE),
"ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN),
),
)
}
}

View file

@ -0,0 +1,169 @@
package com.tangem.data.quotes.multi
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class DefaultMultiQuoteUpdaterTest {
private val appCurrencyResponseStore: AppCurrencyResponseStore = mockk()
private val quotesStore: QuotesStoreV2 = mockk()
private val multiQuoteFetcher: MultiQuoteFetcher = mockk()
private val multiQuoteUpdater = DefaultMultiQuoteUpdater(
appCurrencyResponseStore = appCurrencyResponseStore,
quotesStore = quotesStore,
multiQuoteFetcher = multiQuoteFetcher,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `test that initial app currency is skipped`() = runTest {
val appCurrencyFlow = flowOf(null, usdAppCurrency)
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
coEvery { multiQuoteFetcher(params) } returns Unit.right()
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
coVerify(exactly = 1) { appCurrencyResponseStore.get() }
val values = getEmittedValues(actual)
Truth.assertThat(values).isEqualTo(listOf(Unit.right()))
coVerifyOrder {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(params)
}
}
@Test
fun `test that flow is filtered the same status`() = runTest {
val appCurrencyFlow = flowOf(usdAppCurrency, usdAppCurrency)
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
coEvery { multiQuoteFetcher(params) } returns Unit.right()
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
coVerify(exactly = 1) { appCurrencyResponseStore.get() }
val values = getEmittedValues(actual)
Truth.assertThat(values).isEqualTo(listOf(Unit.right()))
coVerifyOrder {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(params)
}
}
@Test
fun `test that flow is filtered the null`() = runTest {
val appCurrencyFlow = flowOf(null, null)
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
coVerify(exactly = 1) { appCurrencyResponseStore.get() }
val values = getEmittedValues(actual)
Truth.assertThat(values.size).isEqualTo(0)
coVerify(inverse = true) {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(any())
}
}
@Test
fun `test if flow throws exception`() = runTest {
val exception = IllegalStateException()
val innerFlow = MutableStateFlow(value = false)
val appCurrencyFlow = flow {
if (innerFlow.value) {
emitAll(flowOf(null, usdAppCurrency))
} else {
throw exception
}
}
.buffer(capacity = 5)
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
coEvery { multiQuoteFetcher(params) } returns Unit.right()
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
coVerify(exactly = 1) { appCurrencyResponseStore.get() }
val values1 = getEmittedValues(actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first().isLeft()).isTrue()
Truth.assertThat(values1.first().leftOrNull()).isInstanceOf(exception::class.java)
Truth.assertThat(values1.first().leftOrNull()).hasMessageThat().isEqualTo(exception.message)
coVerify(inverse = true) {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(any())
}
innerFlow.emit(value = true)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2).isEqualTo(listOf(Unit.right()))
coVerifyOrder {
quotesStore.getAllSyncOrNull()
multiQuoteFetcher(params)
}
}
@Test
fun `subscribe and unsubscribe successfully`() {
every { appCurrencyResponseStore.get() } returns emptyFlow()
val updaterJobHolder = multiQuoteUpdater.getUpdaterJobHolder()
Truth.assertThat(updaterJobHolder.isEmpty()).isTrue()
multiQuoteUpdater.subscribe()
Truth.assertThat(updaterJobHolder.isEmpty()).isFalse()
multiQuoteUpdater.unsubscribe()
Truth.assertThat(updaterJobHolder.isEmpty()).isTrue()
}
private companion object {
val usdAppCurrency = CurrenciesResponse.Currency(
id = "USD".lowercase(),
code = "USD",
name = "US Dollar",
unit = "$",
type = "fiat",
rateBTC = "",
)
}
}

View file

@ -0,0 +1,177 @@
package com.tangem.data.quotes.single
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.quotes.store.QuotesStoreV2
import com.tangem.domain.models.StatusSource
import com.tangem.domain.quotes.single.SingleQuoteProducer
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class DefaultSingleQuoteProducerTest {
private val params = SingleQuoteProducer.Params(
rawCurrencyId = CryptoCurrency.RawID(value = "BTC"),
)
private val quotesStore = mockk<QuotesStoreV2>()
private val producer = DefaultSingleQuoteProducer(
params = params,
quotesStore = quotesStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `test that flow is mapped for network from params`() = runTest {
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
val storeQuote = flowOf(
setOf(
status,
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
),
)
every { quotesStore.get() } returns storeQuote
val actual = producer.produce()
verify { quotesStore.get() }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(status))
}
@Test
fun `test that flow is updated if quote is updated`() = runTest {
val storeQuote = MutableSharedFlow<Set<Quote>>(replay = 2, extraBufferCapacity = 1)
every { quotesStore.get() } returns storeQuote
val actual = producer.produceWithFallback()
verify { quotesStore.get() }
// first emit
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
storeQuote.emit(value = setOf(status))
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
// second emit
val updatedStatus = Quote.Value(
rawCurrencyId = params.rawCurrencyId,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
)
storeQuote.emit(value = setOf(updatedStatus))
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus))
}
@Test
fun `test that flow is filtered the same status`() = runTest {
val storeQuote = MutableSharedFlow<Set<Quote>>(replay = 2, extraBufferCapacity = 1)
every { quotesStore.get() } returns storeQuote
val actual = producer.produceWithFallback()
verify { quotesStore.get() }
// first emit
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
storeQuote.emit(value = setOf(status))
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
// second emit
storeQuote.emit(value = setOf(status))
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
}
@Test
fun `test if flow throws exception`() = runTest {
val exception = IllegalStateException()
val status = Quote.Value(
rawCurrencyId = params.rawCurrencyId,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
)
val innerFlow = MutableStateFlow(value = false)
val storeQuote = flow {
if (innerFlow.value) {
emit(setOf(status))
} else {
throw exception
}
}
.buffer(capacity = 5)
every { quotesStore.get() } returns storeQuote
val actual = producer.produceWithFallback()
verify { quotesStore.get() }
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
val fallbackStatus = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus))
innerFlow.emit(value = true)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
}
@Test
fun `test if flow doesn't contain network from params`() = runTest {
val storeFlow = flowOf(
setOf(
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
),
)
every { quotesStore.get() } returns storeFlow
val actual = producer.produceWithFallback()
verify { quotesStore.get() }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(0)
}
}

View file

@ -0,0 +1,92 @@
package com.tangem.data.quotes.store
import com.google.common.truth.Truth
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class QuotesStoreGetMethodTest {
private val runtimeStore = RuntimeSharedStore<Set<Quote>>()
private val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
private val store = DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `test get if runtime store is empty`() = runTest {
val actual = store.get()
val values = getEmittedValues(flow = actual)
Truth.assertThat(values).isEqualTo(emptyList<Set<Quote>>())
}
@Test
fun `test get if runtime store contains empty set`() = runTest {
runtimeStore.store(value = emptySet())
val actual = store.get()
val values = getEmittedValues(flow = actual)
Truth.assertThat(values).isEqualTo(listOf(emptySet<Quote>()))
}
@Test
fun `test get if runtime store is not empty`() = runTest {
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain()))
val actual = store.get()
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(setOf(btcQuote.toDomain(), ethQuote.toDomain())))
}
@Test
fun `test getAllSyncOrNull if runtime store is empty`() = runTest {
val actual = store.getAllSyncOrNull()
Truth.assertThat(actual).isEqualTo(null)
}
@Test
fun `test getAllSyncOrNull if runtime store contains empty set`() = runTest {
runtimeStore.store(value = emptySet())
val actual = store.getAllSyncOrNull()
Truth.assertThat(actual).isEqualTo(emptySet<Quote>())
}
@Test
fun `test getAllSyncOrNull if runtime store is not empty`() = runTest {
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain()))
val actual = store.getAllSyncOrNull()
val expected = setOf(btcQuote.toDomain(), ethQuote.toDomain())
Truth.assertThat(actual).isEqualTo(expected)
}
}

View file

@ -0,0 +1,82 @@
package com.tangem.data.quotes.store
import androidx.datastore.core.DataStore
import com.google.common.truth.Truth
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class QuotesStoreInitializationTest {
@Test
fun `test initialization if cache store is empty`() = runTest {
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
val persistenceStore: DataStore<CurrencyIdWithQuote> = mockk()
every { persistenceStore.data } returns emptyFlow()
DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
}
@Test
fun `test initialization if cache store contains empty map`() = runTest {
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
}
@Test
fun `test initialization if cache store is not empty`() = runTest {
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
persistenceStore.updateData {
it.toMutableMap().apply {
this += btcQuote
this += ethQuote
}
}
DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
val expected = setOf(
btcQuote.toDomain(source = StatusSource.CACHE),
ethQuote.toDomain(source = StatusSource.CACHE),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected)
}
}

View file

@ -0,0 +1,131 @@
package com.tangem.data.quotes.store
import com.google.common.truth.Truth
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class QuotesStoreUpdateMethodsTest {
private val runtimeStore = RuntimeSharedStore<Set<Quote>>()
private val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
private val store = DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `refresh if runtime store is empty`() = runTest {
val currenciesIds = setOf(
CryptoCurrency.RawID(value = "BTC"),
CryptoCurrency.RawID(value = "ETH"),
)
store.refresh(currenciesIds = currenciesIds)
val runtimeExpected = currenciesIds.map(Quote::Empty).toSet()
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
}
@Test
fun `refresh if runtime store contains quote with this id`() = runTest {
val quote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL)
runtimeStore.store(value = setOf(quote))
store.refresh(currenciesIds = setOf(quote.rawCurrencyId))
val runtimeExpected = setOf(quote.copySealed(source = StatusSource.CACHE))
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
}
@Test
fun `store actual if runtime and cache stores contain quotes with this id`() = runTest {
val prevStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
runtimeStore.store(
value = setOf(
prevStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE),
),
)
persistenceStore.updateData {
it.toMutableMap().apply {
put("BTC", prevStatus)
}
}
val newStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN)
store.storeActual(values = mapOf("BTC" to newStatus))
val runtimeExpected = setOf(
newStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL),
)
val persistenceExpected = mapOf("BTC" to newStatus)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected)
}
@Test
fun `store error if runtime store is empty`() = runTest {
val currenciesIds = setOf(
CryptoCurrency.RawID(value = "BTC"),
CryptoCurrency.RawID(value = "ETH"),
)
store.storeError(currenciesIds = currenciesIds)
val runtimeExpected = currenciesIds.map(Quote::Empty).toSet()
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
}
@Test
fun `store error if runtime store contains status with this network`() = runTest {
val status = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
runtimeStore.store(
value = setOf(
status.toDomain(rawCurrencyId = "BTC", source = StatusSource.CACHE),
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
),
)
store.storeError(
currenciesIds = setOf(
CryptoCurrency.RawID(value = "BTC"),
CryptoCurrency.RawID(value = "ETH"),
),
)
val runtimeExpected = setOf(
status.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE),
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
}
}

View file

@ -8,7 +8,7 @@ import com.tangem.datasource.api.tangemTech.models.QuotesResponse
/**
* Adapter to replace unsupported currencies for quotes request if it necessary
*/
internal class QuotesUnsupportedCurrenciesIdAdapter {
class QuotesUnsupportedCurrenciesIdAdapter {
/**
* Replaces unsupported currencies id to it replacements for request

View file

@ -280,4 +280,27 @@ internal class DefaultTransactionRepository(
else -> null
}
}
override suspend fun prepareForSend(
transactionData: TransactionData,
signer: TransactionSigner,
userWalletId: UserWalletId,
network: Network,
): Result<ByteArray> = withContext(coroutineDispatcherProvider.io) {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
val preparer = walletManager as? TransactionPreparer ?: kotlin.run {
Timber.e("${walletManager?.wallet?.blockchain} does not support TransactionBuilder")
error("Wallet manager does not support TransactionPreparer")
}
when (val prepareForSend = preparer.prepareForSend(transactionData, signer)) {
is com.tangem.blockchain.extensions.Result.Failure -> Result.failure(prepareForSend.error)
is com.tangem.blockchain.extensions.Result.Success -> Result.success(prepareForSend.data)
}
}
}

View file

@ -1,15 +1,60 @@
package com.tangem.data.visa.converter
import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse
import com.tangem.domain.visa.model.VisaActivationOrderInfo
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.utils.converter.Converter
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VisaActivationStatusConverter @Inject constructor() :
Converter<CardActivationRemoteStateResponse, VisaActivationRemoteState> {
private val lastUpdatedAt = MutableStateFlow<String?>(null)
override fun convert(value: CardActivationRemoteStateResponse): VisaActivationRemoteState {
// handle pin code error
// either we entered pin code before with an error (WasError) or after receiving an error (InProgress)
if (
value.status == Status.AwaitingPin.stringValue &&
value.stepChangeCode != null &&
value.stepChangeCode == PIN_CODE_VALIDATION_ERROR
) {
return if (lastUpdatedAt.value != value.updatedAt) {
lastUpdatedAt.value == value.updatedAt
VisaActivationRemoteState.AwaitingPinCode(
activationOrderInfo = value.activationOrder!!.convert(),
status = VisaActivationRemoteState.AwaitingPinCode.Status.WasError,
)
} else {
VisaActivationRemoteState.AwaitingPinCode(
activationOrderInfo = value.activationOrder!!.convert(),
status = VisaActivationRemoteState.AwaitingPinCode.Status.InProgress,
)
}
}
// TODO Will be implemented in the future
return VisaActivationRemoteState.Activated
}
private fun CardActivationRemoteStateResponse.ActivationOrder.convert(): VisaActivationOrderInfo {
return VisaActivationOrderInfo(
orderId = id,
customerId = customerId,
customerWalletAddress = customerWalletAddress,
)
}
private enum class Status(val stringValue: String) {
AwaitingPin("AWAITING_PIN"),
// TODO complete statuses list when backend is ready
}
private companion object {
const val PIN_CODE_VALIDATION_ERROR = 1000
}
}

View file

@ -14,6 +14,7 @@ dependencies {
/* Project - Domain */
implementation(projects.domain.walletConnect)
implementation(projects.domain.walletConnect.models)
implementation(projects.domain.transaction)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens)
@ -35,6 +36,7 @@ dependencies {
/* Tangem libraries */
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
/* Reown - WalletConnect */
implementation(deps.reownCore) {
@ -47,4 +49,11 @@ dependencies {
/* Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
/* Tests */
testImplementation(projects.common.test)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.turbine)
}

View file

@ -5,27 +5,25 @@ import com.squareup.moshi.Moshi
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.walletconnect.DefaultWalletConnectRepository
import com.tangem.data.walletconnect.initialize.DefaultWcInitializeUseCase
import com.tangem.data.walletconnect.model.NamespaceKey
import com.tangem.data.walletconnect.network.ethereum.WcEthNetwork
import com.tangem.data.walletconnect.network.solana.WcSolanaNetwork
import com.tangem.data.walletconnect.pair.AssociateNetworksDelegate
import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate
import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
import com.tangem.data.walletconnect.pair.WcPairSdkDelegate
import com.tangem.data.walletconnect.request.DefaultWcRequestService
import com.tangem.data.walletconnect.request.WcMethodHandler
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter
import com.tangem.data.walletconnect.respond.DefaultWcRespondService
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.datasource.di.SdkMoshi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletconnect.model.WcMethod
import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository
import com.tangem.domain.walletconnect.repository.WalletConnectRepository
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.request.WcRequestService
import com.tangem.domain.walletconnect.respond.WcRespondService
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -58,12 +56,12 @@ internal object WalletConnectDataModule {
application: Application,
sessionsManager: DefaultWcSessionsManager,
networkService: DefaultWcRequestService,
wcPairFlow: DefaultWcPairUseCase,
pairSdkDelegate: WcPairSdkDelegate,
): WcInitializeUseCase = DefaultWcInitializeUseCase(
application = application,
sessionsManager = sessionsManager,
networkService = networkService,
wcPairFlow = wcPairFlow,
pairSdkDelegate = pairSdkDelegate,
)
@Provides
@ -72,16 +70,22 @@ internal object WalletConnectDataModule {
sessionsManager: WcSessionsManager,
associateNetworksDelegate: AssociateNetworksDelegate,
caipNamespaceDelegate: CaipNamespaceDelegate,
sdkDelegate: WcPairSdkDelegate,
): DefaultWcPairUseCase = DefaultWcPairUseCase(
sessionsManager = sessionsManager,
associateNetworksDelegate = associateNetworksDelegate,
caipNamespaceDelegate = caipNamespaceDelegate,
sdkDelegate = sdkDelegate,
)
@Provides
@Singleton
fun wcPairUseCase(default: DefaultWcPairUseCase): WcPairUseCase = default
@Provides
@Singleton
fun sdkDelegate(): WcPairSdkDelegate = WcPairSdkDelegate()
@Provides
@Singleton
fun defaultWcSessionsManager(
@ -107,38 +111,37 @@ internal object WalletConnectDataModule {
@Provides
@Singleton
fun defaultWcRequestService(
sessionsManager: WcSessionsManager,
respondService: WcRespondService,
dispatchers: CoroutineDispatcherProvider,
diHelperBox: DiHelperBox,
): DefaultWcRequestService {
val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
return DefaultWcRequestService(
sessionsManager = sessionsManager,
respondService = respondService,
requestAdapters = diHelperBox.handlers,
requestConverters = diHelperBox.handlers,
scope = scope,
)
}
@Provides
@Singleton
fun wcRequestService(default: DefaultWcRequestService): WcRequestService = default
@Provides
@Singleton
fun wcRespondService(): WcRespondService = DefaultWcRespondService()
@Provides
@Singleton
fun wcEthNetwork(@SdkMoshi moshi: Moshi, respondService: WcRespondService): WcEthNetwork = WcEthNetwork(
fun wcEthNetwork(
@SdkMoshi moshi: Moshi,
excludedBlockchains: ExcludedBlockchains,
sessionsManager: WcSessionsManager,
): WcEthNetwork = WcEthNetwork(
moshi = moshi,
respondService = respondService,
excludedBlockchains = excludedBlockchains,
sessionsManager = sessionsManager,
)
@Provides
@Singleton
fun wcSolanaNetwork(): WcSolanaNetwork = WcSolanaNetwork()
fun wcSolanaNetwork(excludedBlockchains: ExcludedBlockchains): WcSolanaNetwork = WcSolanaNetwork(
excludedBlockchains = excludedBlockchains,
)
@Provides
@Singleton
@ -156,12 +159,10 @@ internal object WalletConnectDataModule {
diHelperBox: DiHelperBox,
getWallets: GetWalletsUseCase,
currenciesRepository: CurrenciesRepository,
excludedBlockchains: ExcludedBlockchains,
): AssociateNetworksDelegate = AssociateNetworksDelegate(
namespaceConverters = diHelperBox.converters,
getWallets = getWallets,
currenciesRepository = currenciesRepository,
excludedBlockchains = excludedBlockchains,
)
@Provides
@ -170,14 +171,14 @@ internal object WalletConnectDataModule {
handlers = setOf(
ethNetwork,
),
converters = buildMap {
ethNetwork.namespaceKey to ethNetwork
solanaNetwork.namespaceKey to solanaNetwork
},
converters = setOf(
ethNetwork,
solanaNetwork,
),
)
internal class DiHelperBox(
val converters: Map<NamespaceKey, WcNamespaceConverter>,
val handlers: Set<WcMethodHandler<WcMethod>>,
val converters: Set<WcNamespaceConverter>,
val handlers: Set<WcRequestToUseCaseConverter>,
)
}

View file

@ -6,7 +6,7 @@ import com.reown.android.CoreClient
import com.reown.android.relay.ConnectionType
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
import com.tangem.data.walletconnect.pair.WcPairSdkDelegate
import com.tangem.data.walletconnect.request.DefaultWcRequestService
import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager
import com.tangem.data.walletconnect.utils.WcSdkObserver
@ -17,13 +17,13 @@ internal class DefaultWcInitializeUseCase(
private val application: Application,
private val sessionsManager: DefaultWcSessionsManager,
private val networkService: DefaultWcRequestService,
private val wcPairFlow: DefaultWcPairUseCase,
private val pairSdkDelegate: WcPairSdkDelegate,
) : WcInitializeUseCase {
private val wcSdkObservers = mutableSetOf<WcSdkObserver>(
sessionsManager,
networkService,
wcPairFlow,
pairSdkDelegate,
)
override fun init(projectId: String) {

View file

@ -0,0 +1,94 @@
package com.tangem.data.walletconnect.network.ethereum
import arrow.core.left
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils.toKeccak
import com.tangem.blockchain.common.HEX_PREFIX
import com.tangem.blockchain.extensions.isAscii
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toDecompressedPublicKey
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.OnSign
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.domain.walletconnect.model.WcEthMethod
import com.tangem.domain.walletconnect.usecase.ethereum.WcPersonalEthSignUseCase
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
import com.tangem.domain.walletmanager.WalletManagersFacade
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
internal class DefaultWcPersonalEthSignUseCase(
override val respondService: WcRespondService,
override val context: WcMethodUseCaseContext,
private val method: WcEthMethod.PersonalEthSign,
private val walletManagersFacade: WalletManagersFacade,
private val signUseCase: SignUseCase,
) : BaseWcSignUseCase<Nothing, WcPersonalEthSignUseCase.SignModel>(),
WcPersonalEthSignUseCase {
override val onSign: OnSign<WcPersonalEthSignUseCase.SignModel> = collector@{ state ->
val hashToSign = LegacySdkHelper.createMessageData(state.signModel.rawMsg)
val userWallet = session.wallet
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network)
?: return@collector
val signedHash = signUseCase(hashToSign, userWallet, network)
.onLeft { emit(state.toResult(it.left())) }
.getOrNull() ?: return@collector
val respond = EthereumUtils.prepareSignedMessageData(
signedHash = signedHash,
hashToSign = hashToSign,
publicKey = walletManager.wallet.publicKey.blockchainKey.toDecompressedPublicKey(),
)
val wcRespondResult = respondService.respond(rawSdkRequest, respond)
emit(state.toResult(wcRespondResult))
}
override fun invoke(): Flow<WcSignState<WcPersonalEthSignUseCase.SignModel>> = flow {
val model = WcPersonalEthSignUseCase.SignModel(
rawMsg = method.message,
account = method.account,
humanMsg = LegacySdkHelper.hexToAscii(method.message).orEmpty(),
)
emitAll(delegate(model))
}
}
object LegacySdkHelper {
private const val ETH_MESSAGE_PREFIX = "\u0019Ethereum Signed Message:\n"
fun createMessageData(message: String): ByteArray {
val messageData = try {
message.removePrefix(HEX_PREFIX).hexToBytes()
} catch (exception: Exception) {
message.asciiToHex()?.hexToBytes() ?: byteArrayOf()
}
val prefixData = (ETH_MESSAGE_PREFIX + messageData.size.toString()).toByteArray()
return (prefixData + messageData).toKeccak()
}
fun hexToAscii(hex: String): String? {
return try {
hex.removePrefix(HEX_PREFIX).hexToBytes().map {
val char = it.toInt().toChar()
if (char.isAscii()) char else return null
}.joinToString("")
} catch (exception: Exception) {
return null
}
}
private fun String.asciiToHex(): String? {
return map {
if (!it.isAscii()) return null
Integer.toHexString(it.code)
}.joinToString("")
}
}

View file

@ -2,40 +2,50 @@ package com.tangem.data.walletconnect.network.ethereum
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.walletconnect.model.CAIP2
import com.tangem.data.walletconnect.model.NamespaceKey
import com.tangem.data.walletconnect.request.WcMethodHandler
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter.Companion.fromJson
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.walletconnect.model.WcMethod
import com.tangem.domain.walletconnect.model.WcRequest
import com.tangem.domain.walletconnect.respond.WcRespondService
import com.tangem.domain.walletconnect.usecase.WcUseCase
import com.tangem.domain.walletconnect.usecase.WcUseCasesFlowProvider
import com.tangem.domain.walletconnect.usecase.ethereum.EthPersonalSignUseCase
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod.SignMessage
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.walletconnect.model.WcEthMethod
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
import com.tangem.domain.wallets.models.UserWallet
internal class WcEthNetwork(
internal class WcEthNetwork constructor(
private val moshi: Moshi,
private val respondService: WcRespondService,
) : WcMethodHandler<WcEthMethod>, WcUseCasesFlowProvider, WcNamespaceConverter {
private val _useCases: Channel<WcUseCase> = Channel(Channel.BUFFERED)
override val useCases = _useCases.receiveAsFlow()
private val excludedBlockchains: ExcludedBlockchains,
private val sessionsManager: WcSessionsManager,
) : WcRequestToUseCaseConverter, WcNamespaceConverter {
override val namespaceKey: NamespaceKey = NamespaceKey("eip155")
override fun canHandle(methodName: String): Boolean {
return Name.entries.find { it.raw == methodName } != null
override suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase? {
val methodKey = request.request.method
val name = Name.entries.find { it.raw == methodKey } ?: return null
val method: WcEthMethod = name.toMethod(request) ?: return null
val session = sessionsManager.findSessionByTopic(request.topic) ?: return null
val network = toNetwork(request.chainId.orEmpty(), session.wallet) ?: return null
return when (method) {
is WcEthMethod.PersonalEthSign -> TODO()
}
}
override fun deserialize(methodName: String, params: String): WcEthMethod? {
val name = Name.entries.find { it.raw == methodName } ?: return null
return when (name) {
Name.Sign -> TODO()
Name.PersonalSign -> WcMethodHandler.fromJson<SignMessage>(params, moshi)
private fun Name.toMethod(request: WcSdkSessionRequest): WcEthMethod? {
val rawParams = request.request.params
return when (this) {
Name.EthSign,
Name.PersonalSign,
-> moshi.fromJson<List<String>>(rawParams)?.let { list ->
val accountIndex = if (this == Name.EthSign) 0 else 1
val messageIndex = if (this == Name.EthSign) 1 else 0
val account = list.getOrNull(accountIndex) ?: return@let null
val message = list.getOrNull(messageIndex) ?: return@let null
WcEthMethod.PersonalEthSign(account = account, message = message)
}
Name.SignTypeData -> TODO()
Name.SignTypeDataV4 -> TODO()
Name.SignTransaction -> TODO()
@ -43,16 +53,8 @@ internal class WcEthNetwork(
}
}
override fun handle(wcRequest: WcRequest<WcMethod>) {
wcRequest as WcRequest<WcEthMethod>
val useCase = when (wcRequest.method) {
is SignMessage -> EthPersonalSignUseCase(wcRequest as WcRequest<SignMessage>, respondService)
}
_useCases.trySend(useCase)
}
enum class Name(val raw: String) {
Sign("eth_sign"),
EthSign("eth_sign"),
PersonalSign("personal_sign"),
SignTypeData("eth_signTypedData"),
SignTypeDataV4("eth_signTypedData_v4"),
@ -60,13 +62,18 @@ internal class WcEthNetwork(
SendTransaction("eth_sendTransaction"),
}
override fun toNetwork(chainId: String, wallet: UserWallet): Network? {
return toNetwork(chainId, wallet, excludedBlockchains)
}
override fun toBlockchain(chainId: CAIP2): Blockchain? {
if (chainId.namespace != namespaceKey.key) return null
val ethChainId = chainId.reference.toIntOrNull() ?: return null
return Blockchain.fromChainId(ethChainId)
}
override fun toCAIP2(blockchain: Blockchain): CAIP2? {
override fun toCAIP2(network: Network): CAIP2? {
val blockchain = Blockchain.fromId(network.id.value)
if (!blockchain.isEvm()) return null
val chainId = blockchain.getChainId() ?: return null
return CAIP2(

View file

@ -1,11 +1,17 @@
package com.tangem.data.walletconnect.network.solana
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.walletconnect.model.CAIP2
import com.tangem.data.walletconnect.model.NamespaceKey
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
internal class WcSolanaNetwork constructor(
private val excludedBlockchains: ExcludedBlockchains,
) : WcNamespaceConverter {
internal class WcSolanaNetwork : WcNamespaceConverter {
override val namespaceKey: NamespaceKey = NamespaceKey("solana")
override fun toBlockchain(chainId: CAIP2): Blockchain? {
@ -17,7 +23,12 @@ internal class WcSolanaNetwork : WcNamespaceConverter {
}
}
override fun toCAIP2(blockchain: Blockchain): CAIP2? {
override fun toNetwork(chainId: String, wallet: UserWallet): Network? {
return toNetwork(chainId, wallet, excludedBlockchains)
}
override fun toCAIP2(network: Network): CAIP2? {
val blockchain = Blockchain.fromId(network.id.value)
val chainId = when (blockchain) {
Blockchain.Solana -> MAINNET_CHAIN_ID
Blockchain.SolanaTestnet -> TESTNET_CHAIN_ID

View file

@ -1,92 +1,72 @@
package com.tangem.data.walletconnect.pair
import com.reown.walletkit.client.Wallet
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.getNetwork
import com.tangem.data.walletconnect.model.CAIP2
import com.tangem.data.walletconnect.model.NamespaceKey
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletconnect.model.WcNetwork
import com.tangem.domain.walletconnect.model.WcPairError
import com.tangem.domain.walletconnect.model.WcSessionProposal.ProposalNetwork
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
internal class AssociateNetworksDelegate constructor(
private val namespaceConverters: Map<NamespaceKey, WcNamespaceConverter>,
private val namespaceConverters: Set<WcNamespaceConverter>,
private val getWallets: GetWalletsUseCase,
private val currenciesRepository: CurrenciesRepository,
private val excludedBlockchains: ExcludedBlockchains,
) {
@Throws(WcPairError.UnsupportedNetworks::class)
suspend fun associate(sessionProposal: Wallet.Model.SessionProposal): Map<UserWalletId, ProposalNetwork> {
suspend fun associate(sessionProposal: Wallet.Model.SessionProposal): Map<UserWallet, ProposalNetwork> {
val userWallets = getWallets.invokeSync().filter { it.isMultiCurrency }
val requiredNamespaces: Set<CAIP2> = sessionProposal.requiredNamespaces.setOfChainId()
val optionalNamespaces: Set<CAIP2> = sessionProposal.optionalNamespaces.setOfChainId()
val requiredNamespaces: Set<String> = sessionProposal.requiredNamespaces.setOfChainId()
val optionalNamespaces: Set<String> = sessionProposal.optionalNamespaces.setOfChainId()
return userWallets.associate { wallet ->
wallet.walletId to mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces)
}
return userWallets
.associateWith { wallet -> mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces) }
}
private suspend fun mapNetworksForWallet(
wallet: UserWallet,
requiredNamespaces: Set<CAIP2>,
optionalNamespaces: Set<CAIP2>,
requiredNamespaces: Set<String>,
optionalNamespaces: Set<String>,
): ProposalNetwork {
val walletNetworks = currenciesRepository.getMultiCurrencyWalletCurrenciesSync(wallet.walletId)
.filterIsInstance<CryptoCurrency.Coin>()
.map { it.network }
val unknownRequired = mutableSetOf<WcNetwork.Unknown>()
val missingRequired = mutableSetOf<WcNetwork.Supported>()
val required = mutableSetOf<WcNetwork.Supported>()
val available = mutableSetOf<WcNetwork.Supported>()
val notAdded = mutableSetOf<WcNetwork.Supported>()
fun CAIP2.toBlockchain() = namespaceConverters[NamespaceKey(this.namespace)]?.toBlockchain(this)
fun Blockchain.toNetwork() = getNetwork(
blockchain = this,
extraDerivationPath = null,
scanResponse = wallet.scanResponse,
excludedBlockchains = excludedBlockchains,
)
val unknownRequired = mutableSetOf<String>()
val missingRequired = mutableSetOf<Network>()
val required = mutableSetOf<Network>()
val available = mutableSetOf<Network>()
val notAdded = mutableSetOf<Network>()
requiredNamespaces.forEach { chainId ->
val blockchain = chainId.toBlockchain()
if (blockchain == null) {
unknownRequired.add(WcNetwork.Unknown(missingNetworkName(chainId)))
return@forEach
}
val wcNetwork = blockchain.toNetwork()
val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) }
if (wcNetwork == null) {
unknownRequired.add(WcNetwork.Unknown(missingNetworkName(blockchain)))
unknownRequired.add(missingNetworkName(chainId))
return@forEach
}
val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id }
if (walletNetwork == null) {
missingRequired.add(WcNetwork.Supported(wcNetwork))
missingRequired.add(wcNetwork)
} else {
required.add(WcNetwork.Supported(walletNetwork))
required.add(walletNetwork)
}
}
optionalNamespaces.forEach { chainId ->
val wcNetwork = chainId.toBlockchain()?.toNetwork() ?: return@forEach
val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) }
?: return@forEach
val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id }
if (walletNetwork == null) {
available.add(WcNetwork.Supported(wcNetwork))
available.add(wcNetwork)
} else {
notAdded.add(WcNetwork.Supported(walletNetwork))
notAdded.add(walletNetwork)
}
}
if (unknownRequired.isNotEmpty()) throw WcPairError.UnsupportedNetworks(unknownRequired)
return ProposalNetwork(
walletId = wallet.walletId,
wallet = wallet,
missingRequired = missingRequired,
required = required,
available = available,
@ -94,10 +74,8 @@ internal class AssociateNetworksDelegate constructor(
)
}
private fun Map<String, Wallet.Model.Namespace.Proposal>.setOfChainId(): Set<CAIP2> =
this.values.flatMap { proposal -> proposal.chains ?: listOf() }
.mapNotNull { rawChainId -> CAIP2.fromRaw(rawChainId) }.toSet()
private fun Map<String, Wallet.Model.Namespace.Proposal>.setOfChainId(): Set<String> =
this.values.flatMap { proposal -> proposal.chains ?: listOf() }.toSet()
private fun missingNetworkName(chainId: CAIP2): String = chainId.namespace.replaceFirstChar(Char::titlecase)
private fun missingNetworkName(blockchain: Blockchain): String = blockchain.getCoinName()
private fun missingNetworkName(chainId: String): String = chainId.replaceFirstChar(Char::titlecase)
}

Some files were not shown because too many files have changed in this diff Show more