Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-04 11:43:37 +03:00
commit 9d71729f72
35 changed files with 347 additions and 203 deletions

View file

@ -1,5 +1,6 @@
package com.tangem.tap
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.core.os.bundleOf
@ -20,11 +21,15 @@ internal class DecomposeFragment : ComposeFragment() {
@Inject
override lateinit var uiDependencies: UiDependencies
private val component by lazy(mode = LazyThreadSafetyMode.NONE) {
private lateinit var component: ComposableContentComponent
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val tag = requireArguments().getString(TAG_KEY)
val builder = componentsBuilders[tag]
requireNotNull(builder?.build()) {
component = requireNotNull(builder?.build()) {
"Component builder is not set, call newInstance() for DecomposeFragment creation first."
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.tap
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.tangem.common.routing.AppRoute
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
@ -17,6 +16,7 @@ internal class LockUserWalletsTimer(
private val settingsRepository: SettingsRepository,
private val duration: Duration = with(Duration) { 10.minutes },
private val userWalletsListManager: UserWalletsListManager,
private val coroutineScope: CoroutineScope,
) : LifecycleOwner by owner,
DefaultLifecycleObserver {
@ -31,7 +31,7 @@ internal class LockUserWalletsTimer(
}
override fun onStart(owner: LifecycleOwner) {
owner.lifecycleScope.launch {
coroutineScope.launch {
val wasApplicationStopped = settingsRepository.wasApplicationStopped()
val shouldOpenWelcomeScreenOnResume = settingsRepository.shouldOpenWelcomeScreenOnResume()
@ -59,16 +59,11 @@ internal class LockUserWalletsTimer(
override fun onStop(owner: LifecycleOwner) {
Timber.i("Owner stopped")
owner.lifecycleScope.launch {
coroutineScope.launch {
settingsRepository.setWasApplicationStopped(value = true)
}
}
override fun onDestroy(owner: LifecycleOwner) {
Timber.i("Owner destroyed")
stop()
}
fun restart() {
if (delayJob == null) return
Timber.i(
@ -92,44 +87,30 @@ internal class LockUserWalletsTimer(
delayJob = createDelayJob()
}
private fun stop(log: Boolean = true) {
if (log) {
Timber.i(
"""
Timer stop
|- Was started: ${delayJob?.isActive ?: false}
""".trimIndent(),
)
}
delayJob = null
}
private fun createDelayJob(): Job = lifecycleScope.launch(Dispatchers.Default) {
private fun createDelayJob(): Job = coroutineScope.launch {
val startTime = System.currentTimeMillis()
delay(duration)
if (isActive) {
val userWalletsListManager = userWalletsListManager.asLockable() ?: return@launch
val userWalletsListManager = userWalletsListManager.asLockable() ?: return@launch
if (userWalletsListManager.hasUserWallets) {
val currentTime = System.currentTimeMillis()
val wasApplicationStopped = settingsRepository.wasApplicationStopped()
if (userWalletsListManager.hasUserWallets) {
val currentTime = System.currentTimeMillis()
val wasApplicationStopped = settingsRepository.wasApplicationStopped()
Timber.i(
"""
Timber.i(
"""
Finished
|- App is stopped: $wasApplicationStopped
|- Millis passed: ${currentTime - startTime}
""".trimIndent(),
)
""".trimIndent(),
)
userWalletsListManager.lock()
if (wasApplicationStopped) {
settingsRepository.setShouldOpenWelcomeScreenOnResume(value = true)
} else {
store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) }
}
userWalletsListManager.lock()
if (wasApplicationStopped) {
settingsRepository.setShouldOpenWelcomeScreenOnResume(value = true)
} else {
store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) }
}
}
}

View file

@ -312,7 +312,7 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
context = rootComponentContext,
)
appRouterConfig.routerScope = lifecycleScope
appRouterConfig.routerScope = mainScope
appRouterConfig.componentRouter = routingComponent.router
appRouterConfig.snackbarHandler = this
@ -353,6 +353,7 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
owner = this,
settingsRepository = settingsRepository,
userWalletsListManager = userWalletsListManager,
coroutineScope = mainScope,
)
initIntentHandlers()
@ -433,6 +434,9 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
override fun onDestroy() {
intentProcessor.removeAll()
// workaround: kill process when activity destroy to avoid state when lock() wallets
// and navigation to unlock screen was skipped because system kills activity but not process
android.os.Process.killProcess(android.os.Process.myPid())
super.onDestroy()
}

View file

@ -4,6 +4,7 @@ import com.tangem.domain.card.repository.DerivationsRepository
import com.tangem.domain.managetokens.*
import com.tangem.domain.managetokens.repository.CustomTokensRepository
import com.tangem.domain.managetokens.repository.ManageTokensRepository
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -65,6 +66,7 @@ internal object ManageTokensDomainModule {
currenciesRepository: CurrenciesRepository,
networksRepository: NetworksRepository,
derivationsRepository: DerivationsRepository,
stakingRepository: StakingRepository,
): SaveManagedTokensUseCase {
return SaveManagedTokensUseCase(
customTokensRepository = customTokensRepository,
@ -72,6 +74,7 @@ internal object ManageTokensDomainModule {
currenciesRepository = currenciesRepository,
networksRepository = networksRepository,
derivationsRepository = derivationsRepository,
stakingRepository = stakingRepository,
)
}

View file

@ -238,7 +238,7 @@ internal class ChildFactory @Inject constructor(
params = StakingComponent.Params(
userWalletId = route.userWalletId,
cryptoCurrencyId = route.cryptoCurrencyId,
yield = route.yield,
yieldId = route.yieldId,
),
componentFactory = stakingComponentFactory,
)
@ -561,7 +561,7 @@ internal class ChildFactory @Inject constructor(
params = StakingComponent.Params(
userWalletId = route.userWalletId,
cryptoCurrencyId = route.cryptoCurrencyId,
yield = route.yield,
yieldId = route.yieldId,
),
componentFactory = stakingComponentFactory,
)

View file

@ -10,7 +10,6 @@ import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.serialization.Serializable
@ -186,8 +185,8 @@ sealed class AppRoute(val path: String) : Route {
data class Staking(
val userWalletId: UserWalletId,
val cryptoCurrencyId: CryptoCurrency.ID,
val yield: Yield,
) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrencyId.value}/${yield.id}")
val yieldId: String,
) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrencyId.value}/$yieldId")
@Serializable
data object PushNotification : AppRoute(path = "/push_notification")

View file

@ -72,21 +72,35 @@ internal class DefaultStakingBalanceStore(
override suspend fun store(userWalletId: UserWalletId, items: Set<YieldBalanceWrapperDTO>) {
coroutineScope {
launch {
updateRuntimeStore(userWalletId = userWalletId) {
YieldBalanceConverter(isCached = false).convertSet(input = items)
val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = items)
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(newBalances) { old, new ->
old.integrationId == new.integrationId && old.address == new.address
}
?: newBalances
}
}
}
launch { storeInPersistenceStore(userWalletId = userWalletId, items = items) }
}
}
override suspend fun refresh(userWalletId: UserWalletId) {
override suspend fun refresh(userWalletId: UserWalletId, addressWithIntegrationIdMap: Map<String, String>) {
updateRuntimeStore(userWalletId = userWalletId) { saved ->
saved.mapTo(hashSetOf()) {
when (it) {
is YieldBalance.Data -> it.copy(source = StatusSource.CACHE)
is YieldBalance.Empty -> it.copy(source = StatusSource.CACHE)
is YieldBalance.Error -> it
saved.mapTo(hashSetOf()) { balance ->
val refreshIntegrationId = addressWithIntegrationIdMap[balance.address]
if (balance.integrationId == refreshIntegrationId) {
when (balance) {
is YieldBalance.Data -> balance.copy(source = StatusSource.CACHE)
is YieldBalance.Empty -> balance.copy(source = StatusSource.CACHE)
is YieldBalance.Error -> balance
}
} else {
balance
}
}
}
@ -114,11 +128,13 @@ internal class DefaultStakingBalanceStore(
) {
val newBalance = YieldBalanceConverter(isCached = false).convert(value = item)
val balances = getSyncOrNull(userWalletId)
?.addOrReplace(newBalance) { it.integrationId == integrationId && it.address == address }
?: setOf(newBalance)
updateRuntimeStore(userWalletId = userWalletId) { balances }
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(newBalance) { it.integrationId == integrationId && it.address == address }
?: setOf(newBalance)
}
}
}
private suspend fun updateRuntimeStore(
@ -135,7 +151,11 @@ internal class DefaultStakingBalanceStore(
private suspend fun storeInPersistenceStore(userWalletId: UserWalletId, items: Set<YieldBalanceWrapperDTO>) {
persistenceStore.updateData { current ->
current.toMutableMap().apply {
this[userWalletId.stringValue] = items
this[userWalletId.stringValue] = current[userWalletId.stringValue]
?.addOrReplace(items = items) { old, new ->
old.integrationId == new.integrationId && old.addresses.address == new.addresses.address
}
?: items
}
}
}
@ -159,6 +179,8 @@ internal class DefaultStakingBalanceStore(
cachedBalances: Set<YieldBalance>,
runtimeBalances: Set<YieldBalance>,
): Set<YieldBalance> {
if (runtimeBalances.isEmpty()) return cachedBalances
return runtimeBalances
.map { runtime ->
runtime.takeIf { runtime !is YieldBalance.Error }

View file

@ -27,5 +27,5 @@ interface StakingBalanceStore {
/** Store [item] by [userWalletId], [integrationId] and [address] */
suspend fun store(userWalletId: UserWalletId, integrationId: String, address: String, item: YieldBalanceWrapperDTO)
suspend fun refresh(userWalletId: UserWalletId)
suspend fun refresh(userWalletId: UserWalletId, addressWithIntegrationIdMap: Map<String, String>)
}

View file

@ -676,7 +676,7 @@
<string name="scan_card_settings_message">Scanne die Karte oder Ring, um ihre Einstellungen zu ändern. Die Änderungen wirken sich nur auf die von dir gescannte Karte oder Ring aus und haben keine Auswirkungen auf andere mit deiner Wallet verknüpften Geräte.</string>
<string name="scan_card_settings_title">Halte deine Karte oder Ring bereit!</string>
<string name="security_alert_title">Sicherheitswarnung</string>
<string name="seed_warning_no">Nein, habe ich nicht</string>
<string name="seed_warning_no">Nein</string>
<string name="seed_warning_yes">Ja, leite mich</string>
<string name="selling_insufficient_balance_alert_message">Dein Konto verfügt nicht über ausreichend Guthaben, um Kryptowährungen zu verkaufen. Bitte zahle den gewünschten Vermögenswert ein, um fortzufahren.</string>
<string name="selling_insufficient_balance_alert_title">Unzureichendes Guthaben</string>

View file

@ -6,7 +6,7 @@
<string name="action_buttons_service_loading_alert_message">これには数秒かかる場合があります。しばらくしてからもう一度お試しください。</string>
<string name="action_buttons_service_loading_alert_title">データはまだ読み込まれていません。</string>
<string name="action_buttons_something_wrong_alert_message">この操作は現在利用できません。しばらくしてからもう一度お試しいただくか、画面を下にスワイプしてデータを更新してください。</string>
<string name="action_buttons_something_wrong_alert_title">ボタンは使用できません</string>
<string name="action_buttons_something_wrong_alert_title">アクションは利用できません</string>
<string name="action_buttons_swap_choose_token">トークンを選択</string>
<string name="action_buttons_swap_empty_search_message">トークンが見つかりませんか?メインページのマーケットセクションに移動し、ポートフォリオに追加してスワップできるようにします。</string>
<string name="action_buttons_swap_no_available_pair_notification_message">選択したトークンと交換できるトークンがありません。別のトークンを選択してください。</string>
@ -913,8 +913,10 @@
<string name="toast_undo">元に戻す</string>
<string name="token_button_unavailability_generic_description">この操作は現在利用できません。しばらくしてからもう一度お試しください。</string>
<string name="token_button_unavailability_reason_buy_unavailable">%sの買付は、現在のプロバイダーではサポートされていませんが、より多くのオプションを追加できるよう取り組んでいます。</string>
<string name="token_button_unavailability_reason_custom_token">カスタムトークンでは使用できません。</string>
<string name="token_button_unavailability_reason_empty_balance_sell">売却できる資金がありません。アカウントに入金して、売却できるようにしてください。</string>
<string name="token_button_unavailability_reason_empty_balance_send">送金する資金がありません。アカウントに入金して、送金できるようにしてください。</string>
<string name="token_button_unavailability_reason_loading">データはまだ読み込まれていません。数秒かかる場合があります。しばらくしてからもう一度お試しください。</string>
<string name="token_button_unavailability_reason_not_exchangeable">%sのスワップは、現在のプロバイダーではサポートされていませんが、より多くのオプションを追加できるよう取り組んでいます。</string>
<string name="token_button_unavailability_reason_out_of_date_balance">表示残高はキャッシュにより古くなっている可能性があります。</string>
<string name="token_button_unavailability_reason_pending_transaction_sell">%sネットワーク上の保留中の取引が完了すると、資金の売却が可能になります。</string>

View file

@ -6,7 +6,7 @@
<string name="action_buttons_service_loading_alert_message">Это может занять несколько секунд. Пожалуйста, попробуйте позже.</string>
<string name="action_buttons_service_loading_alert_title">Данные ещё не загрузились</string>
<string name="action_buttons_something_wrong_alert_message">Это действие в данный момент недоступно, пожалуйста, попробуйте позже или обновите данные, сделав свайп экрана вниз.</string>
<string name="action_buttons_something_wrong_alert_title">Кнопка недоступна</string>
<string name="action_buttons_something_wrong_alert_title">Действие недоступно</string>
<string name="action_buttons_swap_choose_token">Выберите токен</string>
<string name="action_buttons_swap_empty_search_message">Не нашли свой токен? Перейдите в раздел «Рынок» на главной странице и добавьте его в свой портфель для обмена.</string>
<string name="action_buttons_swap_no_available_pair_notification_message">Нет доступных вариантов для обмена с выбранным токеном, пожалуйста, выберите другой.</string>
@ -694,6 +694,7 @@
<string name="scan_card_settings_message">Отсканируйте карту или кольцо, чтобы изменить ее настройки. Изменения затронут только ту карту или кольцо, которые вы отсканировали, и не повлияют на другие устройства, привязанные к вашему кошельку.</string>
<string name="scan_card_settings_title">Приготовьте свой Tangem!</string>
<string name="security_alert_title">Уведомление безопасности</string>
<string name="seed_warning_no">Нет</string>
<string name="seed_warning_yes">Да</string>
<string name="selling_insufficient_balance_alert_message">На вашем балансе недостаточно средств для продажи криптовалюты. Пожалуйста, пополните нужный актив, чтобы продолжить.</string>
<string name="selling_insufficient_balance_alert_title">Недостаточно средств</string>
@ -1113,6 +1114,7 @@
<string name="warning_rate_app_title">Нравится Tangem?</string>
<string name="warning_receive_blocked_hedera_token_association_required_message">Вам необходимо провести ассоциацию токена для того, чтобы иметь возможность принимать его</string>
<string name="warning_rent_fee_title">Необходима плата за аренду сети</string>
<string name="warning_seedphrase_action_required_title">Требуется действие</string>
<string name="warning_seedphrase_contacted_support">Вы обращались в службу поддержки через приложение в течение 7 дней после создания кошелька? Если обращались или не уверены, нажмите «Да» и следуйте инструкциям.</string>
<string name="warning_seedphrase_issue_answer_no">Спасибо! Всё в порядке! Дальнейшие действия не требуются.</string>
<string name="warning_seedphrase_issue_answer_yes">Вы будете перенаправлены на официальный сайт Tangem. Пожалуйста, прочитайте и выполните указанные там инструкции.</string>

View file

@ -694,6 +694,8 @@
<string name="scan_card_settings_message">Відскануйте картку або кільце, щоб змінити її налаштування. Зміни торкнуться лише тої картки або кільця, які ви відсканували, і не вплинуть на інші пристрої, прив\'язані до вашого гаманця.</string>
<string name="scan_card_settings_title">Підготуйте свій Tangem!</string>
<string name="security_alert_title">Оповіщення безпеки</string>
<string name="seed_warning_no">Ні, не робив</string>
<string name="seed_warning_yes">Так</string>
<string name="selling_insufficient_balance_alert_message">На вашому балансі недостатньо коштів для продажу криптовалюти. Будь ласка, внесіть бажаний актив, щоб продовжити.</string>
<string name="selling_insufficient_balance_alert_title">Недостатній баланс</string>
<string name="selling_regional_restriction_alert_message">Продаж криптовалюти у вашому регіоні тимчасово недоступний. Ми активно працюємо над тим, щоб додати цю можливість. Слідкуйте за нашими новинами!</string>
@ -1111,6 +1113,7 @@
<string name="warning_rate_app_title">Подобається Tangem?</string>
<string name="warning_receive_blocked_hedera_token_association_required_message">Вам необхідно провести асоціацію токена, щоб мати можливість приймати його</string>
<string name="warning_rent_fee_title">Необхідна плата за оренду мережі</string>
<string name="warning_seedphrase_action_required_title">Потрібна дія</string>
<string name="warning_seedphrase_contacted_support">Чи зверталися в службу підтримки через додаток протягом 7 днів після створення гаманця? Якщо зверталися або не впевнені, натисніть «Так» і дотримуйтесь інструкцій.</string>
<string name="warning_seedphrase_issue_answer_no">Дякуємо! Все добре! Подальших дій не потрібно.</string>
<string name="warning_seedphrase_issue_answer_yes">Ви будете перенаправлені на офіційний сайт Tangem. Будь ласка, прочитайте та виконайте вказані там інструкції.</string>

View file

@ -6,7 +6,7 @@
<string name="action_buttons_service_loading_alert_message">This may take a few seconds. Please try again later.</string>
<string name="action_buttons_service_loading_alert_title">The data has not loaded yet.</string>
<string name="action_buttons_something_wrong_alert_message">The action is currently unavailable. Please try again later or refresh the data by swiping down on the screen.</string>
<string name="action_buttons_something_wrong_alert_title">Button is unavailable</string>
<string name="action_buttons_something_wrong_alert_title">Action is unavailable</string>
<string name="action_buttons_swap_choose_token">Choose the Token</string>
<string name="action_buttons_swap_empty_search_message">Cant find your token? Go to the Market section on the main page and add it to your portfolio for swapping.</string>
<string name="action_buttons_swap_no_available_pair_notification_message">There are no available tokens to swap with the selected token. Please choose another one.</string>
@ -241,6 +241,7 @@
<string name="details_manage_security_long_tap_description">This mechanism protects against proximity attacks on a card or ring. It will enforce a delay between reception and execution of a command.</string>
<string name="details_manage_security_passcode">Passcode</string>
<string name="details_manage_security_passcode_description">Before executing any command entailing a change of the card state, you will have to enter the passcode.</string>
<string name="details_nft_title">NFT</string>
<string name="details_referral_title">Referral program</string>
<string name="details_row_description_flip_to_hide">Flip your device screen down to quickly hide and show balances</string>
<string name="details_row_subtitle_signed_hashes_format">%s hashes</string>
@ -923,8 +924,10 @@
<string name="toast_undo">Undo</string>
<string name="token_button_unavailability_generic_description">This operation is currently unavailable. Please try again later.</string>
<string name="token_button_unavailability_reason_buy_unavailable">Buying %s is not supported by current providers, but we are working to add more options.</string>
<string name="token_button_unavailability_reason_custom_token">Action is not available for custom tokens.</string>
<string name="token_button_unavailability_reason_empty_balance_sell">You do not have funds to sell. Top up your account to be able to sell funds from it.</string>
<string name="token_button_unavailability_reason_empty_balance_send">You do not have funds to send. Top up your account to be able to send funds from it.</string>
<string name="token_button_unavailability_reason_loading">The data has not loaded yet. This may take a few seconds. Please try again later.</string>
<string name="token_button_unavailability_reason_not_exchangeable">Swapping %s is not supported by current providers, but we are working to add more options.</string>
<string name="token_button_unavailability_reason_out_of_date_balance">The displayed balance might be outdated due to caching.</string>
<string name="token_button_unavailability_reason_pending_transaction_sell">Selling funds will be available once the pending transaction(s) on the %s network is complete.</string>

View file

@ -135,6 +135,12 @@ internal class DefaultStakingRepository(
}
}
override suspend fun getYield(yieldId: String): Yield {
return withContext(dispatchers.io) {
getEnabledYieldsSync().find { it.id == yieldId } ?: error("Staking is unavailable")
}
}
override suspend fun getActions(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
@ -396,13 +402,31 @@ internal class DefaultStakingRepository(
?: YieldBalance.Error(integrationId, address)
}
@Suppress("LongMethod")
override suspend fun fetchMultiYieldBalance(
userWalletId: UserWalletId,
cryptoCurrencies: List<CryptoCurrency>,
refresh: Boolean,
) = withContext(dispatchers.io) {
if (refresh) {
stakingBalanceStore.refresh(userWalletId = userWalletId)
stakingBalanceStore.refresh(
userWalletId = userWalletId,
addressWithIntegrationIdMap = cryptoCurrencies
.mapNotNull { currency ->
val addresses = walletManagersFacade.getAddresses(userWalletId, currency.network)
val integrationId = integrationIdMap[getIntegrationKey(currency.id)]
if (integrationId != null) {
addresses to integrationId
} else {
null
}
}
.flatMap { (addresses, integrationId) ->
addresses.map { address -> integrationId to address.value }
}
.toMap(),
)
}
cacheRegistry.invokeOnExpire(

View file

@ -107,9 +107,10 @@ internal class DefaultCurrenciesRepository(
)
val currenciesToAdd = populateCurrenciesWithMissedCoins(
currencies = filterAlreadyAddedCurrencies(savedCurrencies.tokens, currencies),
)
currencies = currencies,
).let {
filterAlreadyAddedCurrencies(savedCurrencies.tokens, it)
}
val updatedResponse = savedCurrencies.copy(
tokens = savedCurrencies.tokens + currenciesToAdd.map(userTokensResponseFactory::createResponseToken),
)

View file

@ -35,8 +35,22 @@ internal class DefaultQuotesRepository(
private var quotesFetchedForAppCurrency: String? = null
private val mutex = Mutex()
@OptIn(ExperimentalCoroutinesApi::class)
override fun getQuotesUpdates(currenciesIds: Set<CryptoCurrency.RawID>): Flow<Set<Quote>> {
return quotesStore.get(currenciesIds)
return appPreferencesStore.getObject<CurrenciesResponse.Currency>(
key = PreferencesKeys.SELECTED_APP_CURRENCY_KEY,
)
.distinctUntilChanged()
.filterNotNull()
.flatMapLatest { appCurrency ->
fetchExpiredQuotes(
currenciesIds = currenciesIds,
appCurrencyId = appCurrency.id,
refresh = false,
)
quotesStore.get(currenciesIds)
}
.flowOn(dispatchers.io)
}
@ -106,6 +120,7 @@ internal class DefaultQuotesRepository(
val expiredCurrenciesIds = filterExpiredCurrenciesIds(
currenciesIds = currenciesIds,
appCurrencyId = appCurrencyId,
refresh = refresh || quotesFetchedForAppCurrency != appCurrencyId,
)
if (expiredCurrenciesIds.isEmpty()) return
@ -136,7 +151,7 @@ internal class DefaultQuotesRepository(
onError = { error ->
Timber.e(error)
cacheRegistry.invalidate(rawCurrenciesIds.map { getQuoteCacheKey(it) })
cacheRegistry.invalidate(rawCurrenciesIds.map { getQuoteCacheKey(it, appCurrencyId) })
quotesStore.storeEmptyQuotes(currenciesIds = rawCurrenciesIds)
},
)
@ -144,12 +159,13 @@ internal class DefaultQuotesRepository(
private suspend fun filterExpiredCurrenciesIds(
currenciesIds: Set<CryptoCurrency.RawID>,
appCurrencyId: String,
refresh: Boolean,
): Set<CryptoCurrency.RawID> {
return currenciesIds.fold(hashSetOf()) { acc, currencyId ->
if (currencyId !in acc) {
cacheRegistry.invokeOnExpire(
key = getQuoteCacheKey(currencyId),
key = getQuoteCacheKey(currencyId, appCurrencyId),
skipCache = refresh,
block = { acc.add(currencyId) },
)
@ -158,5 +174,7 @@ internal class DefaultQuotesRepository(
}
}
private fun getQuoteCacheKey(rawCurrencyId: CryptoCurrency.RawID): String = "quote_${rawCurrencyId.value}"
private fun getQuoteCacheKey(rawCurrencyId: CryptoCurrency.RawID, appCurrencyId: String): String {
return "quote_${rawCurrencyId.value}_$appCurrencyId"
}
}

View file

@ -73,6 +73,7 @@ private fun ScanResponse.hasDerivation(curve: EllipticCurve, derivationPath: Der
* @return null if wallet is not multi-currency or total cards count
*/
fun ScanResponse.getCardsCount(): Int? {
if (cardTypesResolver.isTangemTwins()) return 2
if (!cardTypesResolver.isMultiwalletAllowed()) return null
return when (val status = card.backupStatus) {

View file

@ -15,6 +15,7 @@ dependencies {
api(projects.domain.core)
implementation(projects.domain.wallets.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.staking)
implementation(projects.domain.tokens)
implementation(projects.domain.card)
implementation(projects.domain.legacy)

View file

@ -5,6 +5,7 @@ import arrow.core.flatten
import com.tangem.domain.card.repository.DerivationsRepository
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.managetokens.repository.CustomTokensRepository
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.CurrenciesRepository
@ -18,6 +19,7 @@ class SaveManagedTokensUseCase(
private val currenciesRepository: CurrenciesRepository,
private val networksRepository: NetworksRepository,
private val derivationsRepository: DerivationsRepository,
private val stakingRepository: StakingRepository,
) {
suspend operator fun invoke(
@ -50,6 +52,8 @@ class SaveManagedTokensUseCase(
existingCurrencies = existingCurrencies,
currenciesToAdd = addingCurrencies,
)
refreshUpdatedYieldBalances(userWalletId, existingCurrencies)
}
private suspend fun removeCurrenciesFromWalletManager(
@ -90,6 +94,17 @@ class SaveManagedTokensUseCase(
)
}
private suspend fun refreshUpdatedYieldBalances(
userWalletId: UserWalletId,
existingCurrencies: List<CryptoCurrency>,
) {
stakingRepository.fetchMultiYieldBalance(
userWalletId = userWalletId,
cryptoCurrencies = existingCurrencies,
refresh = true,
)
}
/**
* Determines if the [existingCurrencies] list contains a coin that corresponds
* to the given [network].

View file

@ -31,36 +31,40 @@ class SaveMarketTokensUseCase(
addedNetworks: Set<TokenMarketInfo.Network>,
removedNetworks: Set<TokenMarketInfo.Network>,
): Either<Throwable, Unit> = Either.catch {
currenciesRepository.removeCurrencies(
userWalletId = userWalletId,
currencies = removedNetworks.mapNotNull {
if (removedNetworks.isNotEmpty()) {
currenciesRepository.removeCurrencies(
userWalletId = userWalletId,
currencies = removedNetworks.mapNotNull {
marketsTokenRepository.createCryptoCurrency(
userWalletId = userWalletId,
token = tokenMarketParams,
network = it,
)
},
)
}
if (addedNetworks.isNotEmpty()) {
derivationsRepository.derivePublicKeysByNetworkIds(
userWalletId = userWalletId,
networkIds = addedNetworks.map { Network.ID(it.networkId) },
)
val addedCurrencies = addedNetworks.mapNotNull {
marketsTokenRepository.createCryptoCurrency(
userWalletId = userWalletId,
token = tokenMarketParams,
network = it,
)
},
)
}
derivationsRepository.derivePublicKeysByNetworkIds(
userWalletId = userWalletId,
networkIds = addedNetworks.map { Network.ID(it.networkId) },
)
currenciesRepository.addCurrencies(userWalletId = userWalletId, currencies = addedCurrencies)
val addedCurrencies = addedNetworks.mapNotNull {
marketsTokenRepository.createCryptoCurrency(
networksRepository.getNetworkStatusesSync(
userWalletId = userWalletId,
token = tokenMarketParams,
network = it,
networks = addedCurrencies.map(CryptoCurrency::network).toSet(),
refresh = true,
)
}
currenciesRepository.addCurrencies(userWalletId = userWalletId, currencies = addedCurrencies)
networksRepository.getNetworkStatusesSync(
userWalletId = userWalletId,
networks = addedCurrencies.map(CryptoCurrency::network).toSet(),
refresh = true,
)
}
}

View file

@ -20,4 +20,10 @@ class GetYieldUseCase(
.catch { stakingRepository.getYield(cryptoCurrencyId, symbol) }
.mapLeft { stakingErrorResolver.resolve(it) }
}
suspend operator fun invoke(yieldId: String): Either<StakingError, Yield> {
return Either
.catch { stakingRepository.getYield(yieldId) }
.mapLeft { stakingErrorResolver.resolve(it) }
}
}

View file

@ -33,6 +33,8 @@ interface StakingRepository {
suspend fun getYield(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): Yield
suspend fun getYield(yieldId: String): Yield
suspend fun getStakingAvailability(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): StakingAvailability
suspend fun getActions(

View file

@ -17,6 +17,7 @@ import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.tokens.utils.CurrencyStatusProxyCreator
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
/**
* Base operations for working with currency status
@ -28,6 +29,7 @@ import kotlinx.coroutines.flow.*
*
[REDACTED_AUTHOR]
*/
@Suppress("LargeClass")
abstract class BaseCurrencyStatusOperations(
private val currenciesRepository: CurrenciesRepository,
private val quotesRepository: QuotesRepository,
@ -44,6 +46,13 @@ abstract class BaseCurrencyStatusOperations(
network: Network,
): EitherFlow<Error, Set<NetworkStatus>>
protected abstract suspend fun fetchComponents(
userWalletId: UserWalletId,
networks: Set<Network>,
currenciesIds: Set<CryptoCurrency.ID>,
currencies: List<CryptoCurrency>,
): Either<Throwable, Unit>
suspend fun getCurrencyStatusFlow(
userWalletId: UserWalletId,
currencyId: CryptoCurrency.ID,
@ -93,13 +102,26 @@ abstract class BaseCurrencyStatusOperations(
val yieldBalanceFlow = getYieldBalance(userWalletId = userWalletId, cryptoCurrency = currency)
return combine(quoteFlow, statusFlow, yieldBalanceFlow) { maybeQuote, maybeNetworkStatus, maybeYieldBalance ->
currencyStatusProxyCreator.createCurrencyStatus(
currency = currency,
maybeQuote = maybeQuote,
maybeNetworkStatus = maybeNetworkStatus,
maybeYieldBalance = maybeYieldBalance,
)
return channelFlow {
launch {
fetchComponents(
userWalletId = userWalletId,
networks = nonEmptySetOf(currency.network),
currenciesIds = nonEmptySetOf(currency.id),
currencies = nonEmptyListOf(currency),
)
}
combine(quoteFlow, statusFlow, yieldBalanceFlow) { maybeQuote, maybeNetworkStatus, maybeYieldBalance ->
currencyStatusProxyCreator.createCurrencyStatus(
currency = currency,
maybeQuote = maybeQuote,
maybeNetworkStatus = maybeNetworkStatus,
maybeYieldBalance = maybeYieldBalance,
)
}
.onEach(::send)
.launchIn(this)
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.domain.tokens.operations
import arrow.core.*
import arrow.core.raise.*
import arrow.core.raise.recover
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.core.lce.lce
@ -98,14 +98,14 @@ class CachedCurrenciesStatusesOperations(
.launchIn(scope = this)
}
private suspend fun Raise<TokenListError>.fetchComponents(
override suspend fun fetchComponents(
userWalletId: UserWalletId,
networks: NonEmptySet<Network>,
currenciesIds: NonEmptySet<CryptoCurrency.ID>,
currencies: NonEmptyList<CryptoCurrency>,
) = coroutineScope {
catch(
block = {
networks: Set<Network>,
currenciesIds: Set<CryptoCurrency.ID>,
currencies: List<CryptoCurrency>,
): Either<Throwable, Unit> {
return coroutineScope {
Either.catch {
awaitAll(
async { networksRepository.fetchNetworkStatuses(userWalletId, networks) },
async {
@ -114,11 +114,9 @@ class CachedCurrenciesStatusesOperations(
},
async { stakingRepository.fetchMultiYieldBalance(userWalletId, currencies) },
)
},
catch = {
raise(TokenListError.DataError(it))
},
)
}
.map { }
}
}
private fun createCurrenciesStatuses(

View file

@ -43,6 +43,13 @@ class CurrenciesStatusesOperations(
.onEmpty { emit(Error.EmptyNetworksStatuses.left()) }
}
override suspend fun fetchComponents(
userWalletId: UserWalletId,
networks: Set<Network>,
currenciesIds: Set<CryptoCurrency.ID>,
currencies: List<CryptoCurrency>,
): Either<Throwable, Unit> = Unit.right()
sealed class Error {
data object EmptyCurrencies : Error()

View file

@ -39,78 +39,9 @@ class MockStakingRepository : StakingRepository {
rewardSchedule = Yield.Metadata.RewardSchedule.DAY,
)
override suspend fun getYield(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): Yield = Yield(
id = "1",
token = Token(
name = "Solana",
network = NetworkType.SOLANA,
symbol = "SOL",
decimals = 18,
address = null,
coinGeckoId = "solana",
logoURI = null,
isPoints = null,
),
tokens = listOf(),
args = Yield.Args(
enter = Yield.Args.Enter(
addresses = Yield.Args.Enter.Addresses(
address = AddressArgument(
required = false,
network = null,
minimum = null,
maximum = null,
),
additionalAddresses = mapOf(),
),
args = mapOf(),
),
exit = null,
),
status = Yield.Status(enter = false, exit = null),
apy = 1.toBigDecimal(),
rewardRate = 2.3,
rewardType = Yield.RewardType.APR,
metadata = Yield.Metadata(
name = "Yield",
logoUri = "",
description = "",
documentation = null,
gasFeeToken = Token(
name = "Solana",
network = NetworkType.SOLANA,
symbol = "SOL",
decimals = 18,
address = null,
coinGeckoId = null,
logoURI = null,
isPoints = null,
),
token = Token(
name = "Solana",
network = NetworkType.SOLANA,
symbol = "SOL",
decimals = 18,
address = null,
coinGeckoId = null,
logoURI = null,
isPoints = null,
),
tokens = listOf(),
type = "auto",
rewardSchedule = Yield.Metadata.RewardSchedule.DAY,
cooldownPeriod = Yield.Metadata.Period(days = 1),
warmupPeriod = Yield.Metadata.Period(days = 1),
rewardClaiming = Yield.Metadata.RewardClaiming.AUTO,
defaultValidator = null,
minimumStake = null,
supportsMultipleValidators = false,
revshare = Yield.Metadata.Enabled(enabled = false),
fee = Yield.Metadata.Enabled(enabled = false),
),
validators = listOf(),
isAvailable = false,
)
override suspend fun getYield(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): Yield = yield
override suspend fun getYield(yieldId: String) = yield
override suspend fun getStakingAvailability(
userWalletId: UserWalletId,
@ -245,4 +176,79 @@ class MockStakingRepository : StakingRepository {
override fun getStakingApproval(cryptoCurrency: CryptoCurrency): StakingApproval = StakingApproval.Empty
override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean = false
private companion object {
val yield = Yield(
id = "1",
token = Token(
name = "Solana",
network = NetworkType.SOLANA,
symbol = "SOL",
decimals = 18,
address = null,
coinGeckoId = "solana",
logoURI = null,
isPoints = null,
),
tokens = listOf(),
args = Yield.Args(
enter = Yield.Args.Enter(
addresses = Yield.Args.Enter.Addresses(
address = AddressArgument(
required = false,
network = null,
minimum = null,
maximum = null,
),
additionalAddresses = mapOf(),
),
args = mapOf(),
),
exit = null,
),
status = Yield.Status(enter = false, exit = null),
apy = 1.toBigDecimal(),
rewardRate = 2.3,
rewardType = Yield.RewardType.APR,
metadata = Yield.Metadata(
name = "Yield",
logoUri = "",
description = "",
documentation = null,
gasFeeToken = Token(
name = "Solana",
network = NetworkType.SOLANA,
symbol = "SOL",
decimals = 18,
address = null,
coinGeckoId = null,
logoURI = null,
isPoints = null,
),
token = Token(
name = "Solana",
network = NetworkType.SOLANA,
symbol = "SOL",
decimals = 18,
address = null,
coinGeckoId = null,
logoURI = null,
isPoints = null,
),
tokens = listOf(),
type = "auto",
rewardSchedule = Yield.Metadata.RewardSchedule.DAY,
cooldownPeriod = Yield.Metadata.Period(days = 1),
warmupPeriod = Yield.Metadata.Period(days = 1),
rewardClaiming = Yield.Metadata.RewardClaiming.AUTO,
defaultValidator = null,
minimumStake = null,
supportsMultipleValidators = false,
revshare = Yield.Metadata.Enabled(enabled = false),
fee = Yield.Metadata.Enabled(enabled = false),
),
validators = listOf(),
isAvailable = false,
)
}
}

View file

@ -177,7 +177,7 @@ internal class TokenActionsHandler @AssistedInject constructor(
AppRoute.Staking(
userWalletId = cryptoCurrencyData.userWallet.walletId,
cryptoCurrencyId = cryptoCurrencyData.status.currency.id,
yield = yield,
yieldId = yield.id,
),
)
}

View file

@ -2,7 +2,6 @@ package com.tangem.features.staking.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
@ -11,7 +10,7 @@ interface StakingComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val cryptoCurrencyId: CryptoCurrency.ID,
val yield: Yield,
val yieldId: String,
)
interface Factory : ComponentFactory<Params, StakingComponent>

View file

@ -26,10 +26,7 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.staking.GetActionsUseCase
import com.tangem.domain.staking.InvalidatePendingTransactionsUseCase
import com.tangem.domain.staking.IsAnyTokenStakedUseCase
import com.tangem.domain.staking.IsApproveNeededUseCase
import com.tangem.domain.staking.*
import com.tangem.domain.staking.analytics.StakeScreenSource
import com.tangem.domain.staking.analytics.StakingAnalyticsEvent
import com.tangem.domain.staking.model.StakingApproval
@ -79,6 +76,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.math.BigDecimal
import java.util.concurrent.CopyOnWriteArrayList
@ -116,6 +114,7 @@ internal class StakingModel @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val getActionsUseCase: GetActionsUseCase,
private val getYieldUseCase: GetYieldUseCase,
private val paramsInterceptorHolder: ParamsInterceptorHolder,
private val shareManager: ShareManager,
@DelayedWork private val coroutineScope: CoroutineScope,
@ -136,7 +135,11 @@ internal class StakingModel @Inject constructor(
private val cryptoCurrencyId: CryptoCurrency.ID = params.cryptoCurrencyId
private val userWalletId: UserWalletId = params.userWalletId
private val yield: Yield = params.yield
private val yield: Yield = runBlocking {
getYieldUseCase(params.yieldId).getOrElse {
error("yield must be not null")
}
}
private var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
private var processingActions: List<StakingAction> = emptyList()

View file

@ -5,7 +5,6 @@ import com.tangem.common.routing.AppRouter
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import javax.inject.Inject
@ -38,12 +37,12 @@ internal class DefaultTokenDetailsRouter @Inject constructor(
)
}
override fun openStaking(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, yield: Yield) {
override fun openStaking(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, yieldId: String) {
router.push(
AppRoute.Staking(
userWalletId = userWalletId,
cryptoCurrencyId = cryptoCurrency.id,
yield = yield,
yieldId = yieldId,
),
)
}

View file

@ -1,6 +1,5 @@
package com.tangem.feature.tokendetails.presentation.router
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
@ -17,7 +16,7 @@ internal interface InnerTokenDetailsRouter {
fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency)
fun openStaking(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, yield: Yield)
fun openStaking(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, yieldId: String)
fun openOnrampSuccess(externalTxId: String)
}

View file

@ -971,7 +971,7 @@ internal class TokenDetailsModel @Inject constructor(
error("Staking is unavailable for ${cryptoCurrency.name}")
}
router.openStaking(userWalletId, cryptoCurrency, yield)
router.openStaking(userWalletId, cryptoCurrency, yield.id)
}
}

View file

@ -409,7 +409,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
AppRoute.Staking(
userWalletId = userWalletId,
cryptoCurrencyId = cryptoCurrency.id,
yield = yield ?: return@launch,
yieldId = yield?.id ?: return@launch,
),
)
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.StatusSource
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase
import com.tangem.domain.tokens.error.CurrencyStatusError
@ -42,6 +43,8 @@ internal class GetSingleWalletWarningsFactory @Inject constructor(
) { maybePrimaryCurrencyStatus, isReadyToShowRating, isNeedToBackup, userWallets ->
readyForRateAppNotification = true
buildList {
addUsedOutdatedDataNotification(maybePrimaryCurrencyStatus)
addCriticalNotifications(
cardTypesResolver = cardTypesResolver,
)
@ -68,6 +71,18 @@ internal class GetSingleWalletWarningsFactory @Inject constructor(
}
}
private fun MutableList<WalletNotification>.addUsedOutdatedDataNotification(
maybePrimaryCurrencyStatus: Either<CurrencyStatusError, CryptoCurrencyStatus>,
) {
addIf(
element = WalletNotification.UsedOutdatedData,
condition = maybePrimaryCurrencyStatus.fold(
ifLeft = { false },
ifRight = { it.value.source == StatusSource.ONLY_CACHE },
),
)
}
private fun MutableList<WalletNotification>.addCriticalNotifications(cardTypesResolver: CardTypesResolver) {
addIf(
element = WalletNotification.Critical.DevCard,

View file

@ -44,7 +44,7 @@ internal class SetCryptoCurrencyActionsTransformer(
when (action) {
is TokenActionsState.ActionState.Buy -> {
WalletManageButton.Buy(
enabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
enabled = true,
dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None,
onClick = {
clickIntents.onBuyClick(
@ -56,7 +56,7 @@ internal class SetCryptoCurrencyActionsTransformer(
}
is TokenActionsState.ActionState.Receive -> {
WalletManageButton.Receive(
enabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
enabled = true,
dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None,
onClick = {
clickIntents.onReceiveClick(cryptoCurrencyStatus = cryptoCurrencyStatus)
@ -68,7 +68,7 @@ internal class SetCryptoCurrencyActionsTransformer(
}
is TokenActionsState.ActionState.Sell -> {
WalletManageButton.Sell(
enabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
enabled = true,
dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None,
onClick = {
clickIntents.onSellClick(
@ -80,7 +80,7 @@ internal class SetCryptoCurrencyActionsTransformer(
}
is TokenActionsState.ActionState.Send -> {
WalletManageButton.Send(
enabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
enabled = true,
dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None,
onClick = {
clickIntents.onSendClick(