Updated on 2026-08-14

This commit is contained in:
Tangem 2024-07-22 17:22:29 +01:00
commit d384c68837
21 changed files with 157 additions and 42 deletions

View file

@ -126,6 +126,11 @@ class DialogManager : StoreSubscriber<GlobalState> {
preparedData = state.dialog.data,
context = context,
)
is WalletConnectDialog.PairConnectErrorDialog -> SimpleAlertDialog.create(
titleRes = R.string.wallet_connect_title,
message = state.dialog.error.message,
context = context,
)
is BackupDialog.AttestationFailed -> AttestationFailedDialog.create(context)
is BackupDialog.AddMoreBackupCards -> AddMoreBackupCardsDialog.create(context)
is BackupDialog.BackupInProgress -> BackupInProgressDialog.create(context)

View file

@ -3,6 +3,7 @@ package com.tangem.tap.common.redux.legacy
import com.tangem.domain.redux.LegacyAction
import com.tangem.domain.tokens.utils.convertToAmount
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.feedback.FeedbackEmail
import com.tangem.tap.common.feedback.RateCanBeBetterEmail
import com.tangem.tap.common.feedback.SendTransactionFailedEmail
import com.tangem.tap.common.redux.AppState
@ -21,6 +22,9 @@ internal object LegacyMiddleware {
is LegacyAction.SendEmailRateCanBeBetter -> {
store.state.globalState.feedbackManager?.sendEmail(RateCanBeBetterEmail())
}
is LegacyAction.SendEmailSupport -> {
store.state.globalState.feedbackManager?.sendEmail(FeedbackEmail())
}
is LegacyAction.StartOnboardingProcess -> {
store.dispatch(
GlobalAction.Onboarding.Start(action.scanResponse, canSkipBackup = action.canSkipBackup),

View file

@ -48,21 +48,33 @@ internal class GeneralUserWalletsListManager(
get() = requireImplementation.isLockable
override val userWallets: Flow<List<UserWallet>>
get() = implementation.transformLatest { impl ->
if (impl != null && impl.hasUserWallets) {
emitAll(impl.userWallets)
get() = implementation
.transformLatest { impl ->
if (impl != null) {
emitAll(impl.userWallets)
}
}
}
// To avoid returning empty flow to subscriber while implementation and userWallets are null
// Flow is called first time when implementation is null and then when its assigned with implementation
// that may have not user wallets (null or empty).
// As a result subscription occurs on empty flow, than will not change if user wallets are available
.filter { requireImplementation.hasUserWallets }
override val userWalletsSync: List<UserWallet>
get() = requireImplementation.userWalletsSync
override val selectedUserWallet: Flow<UserWallet>
get() = implementation.transformLatest { impl ->
if (impl != null && impl.hasUserWallets) {
emitAll(impl.selectedUserWallet)
get() = implementation
.transformLatest { impl ->
if (impl != null) {
emitAll(impl.selectedUserWallet)
}
}
}
// To avoid returning empty flow to subscriber while implementation and userWallets are null
// Flow is called first time when implementation is null and then when its assigned with implementation
// that may have not user wallets (null or empty).
// As a result subscription occurs on empty flow, than will not change if user wallets are available
.filter { requireImplementation.hasUserWallets }
override val selectedUserWalletSync: UserWallet?
get() = requireImplementation.selectedUserWalletSync

View file

@ -46,4 +46,8 @@ internal class WalletConnectEventsHandlerImpl : WalletConnectEventsHandler {
override fun onUnsupportedRequest() {
store.dispatchOnMain(WalletConnectAction.RejectUnsupportedRequest)
}
override fun onPairConnectError(error: Throwable) {
store.dispatchOnMain(WalletConnectAction.PairConnectErrorAction(error))
}
}

View file

@ -246,7 +246,20 @@ internal class DefaultLegacyWalletConnectRepository(
}
override fun pair(uri: String) {
Web3Wallet.pair(Wallet.Params.Pair(uri))
Web3Wallet.pair(
params = Wallet.Params.Pair(uri),
onSuccess = {
Timber.i("Paired successfully: $it")
},
onError = {
Timber.e("Error while pairing: $it")
scope.launch {
_events.emit(
WalletConnectEvents.PairConnectError(it.throwable),
)
}
},
)
}
override fun approve(userNamespaces: Map<NetworkNamespace, List<Account>>) {

View file

@ -14,26 +14,24 @@ import com.tangem.tap.domain.walletconnect2.app.TangemWcBlockchainHelper
import com.tangem.tap.domain.walletconnect2.app.WalletConnectEventsHandlerImpl
import com.tangem.tap.domain.walletconnect2.data.DefaultLegacyWalletConnectRepository
import com.tangem.tap.domain.walletconnect2.data.DefaultWalletConnectSessionsRepository
import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor
import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository
import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor
import com.tangem.tap.domain.walletconnect2.domain.WalletConnectSessionsRepository
import com.tangem.tap.domain.walletconnect2.domain.WcJrpcRequestsDeserializer
import com.tangem.tap.domain.walletconnect2.toggles.WalletConnectFeatureToggles
import com.tangem.utils.coroutines.AppCoroutineDispatcherProvider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.scopes.ActivityScoped
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(ActivityComponent::class)
@InstallIn(SingletonComponent::class)
internal object WalletConnectInteractorModule {
@Provides
@ActivityScoped
@Singleton
fun provideWalletConnectInteractor(
wcRepository: LegacyWalletConnectRepository,
wcSessionsRepository: WalletConnectSessionsRepository,
@ -41,6 +39,7 @@ internal object WalletConnectInteractorModule {
currenciesRepository: CurrenciesRepository,
walletManagersFacade: WalletManagersFacade,
userWalletsListManager: UserWalletsListManager,
coroutineDispatcherProvider: CoroutineDispatcherProvider,
): WalletConnectInteractor {
return WalletConnectInteractor(
handler = WalletConnectEventsHandlerImpl(),
@ -51,7 +50,7 @@ internal object WalletConnectInteractorModule {
currenciesRepository = currenciesRepository,
walletManagersFacade = walletManagersFacade,
userWalletsListManager = userWalletsListManager,
dispatchers = AppCoroutineDispatcherProvider(),
dispatchers = coroutineDispatcherProvider,
)
}
}

View file

@ -16,4 +16,6 @@ interface WalletConnectEventsHandler {
fun onSessionRequest(request: WcPreparedRequest)
fun onUnsupportedRequest()
fun onPairConnectError(error: Throwable)
}

View file

@ -9,18 +9,18 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.filterNotNull
import com.tangem.tap.domain.walletconnect.WalletConnectSdkHelper
import com.tangem.tap.domain.walletconnect2.domain.models.*
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction
import com.tangem.tap.features.details.ui.walletconnect.WcSessionForScreen
import com.tangem.tap.store
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.Stack
@Suppress("LargeClass", "LongParameterList")
class WalletConnectInteractor(
@ -35,18 +35,27 @@ class WalletConnectInteractor(
val blockchainHelper: WcBlockchainHelper,
) {
var isWalletConnectReadyForDeepLinks = false
private val getSelectedWalletUseCase by lazy(LazyThreadSafetyMode.NONE) {
GetSelectedWalletUseCase(userWalletsListManager)
}
private val wcScope = CoroutineScope(
Job() + dispatchers.io + FeatureCoroutineExceptionHandler.create("wcScope"),
SupervisorJob() + dispatchers.io + CoroutineExceptionHandler { _, throwable ->
Timber.e("CoroutineException: from: LISTENER SCOPE, exception: $throwable")
},
)
private val listenerScope = CoroutineScope(
Job() + dispatchers.io + FeatureCoroutineExceptionHandler.create("listenScope"),
SupervisorJob() + dispatchers.io + CoroutineExceptionHandler { _, throwable ->
Timber.e("CoroutineException: from: LISTENER SCOPE, exception: $throwable")
},
)
/** Stack of deeplinks to handle if user wallet is not selected or cryptocurrency statuses are not available */
private val deeplinkStack: Stack<String> = Stack()
private val events = walletConnectRepository.events
private val sessions = walletConnectRepository.activeSessions
@ -96,6 +105,7 @@ class WalletConnectInteractor(
networks = currencies.map { it.network },
)
setUserChains(accounts)
handleDeeplinkStack(accounts)
}
private suspend fun startListeningWc(userWalletId: String, cardId: String?) {
@ -118,6 +128,17 @@ class WalletConnectInteractor(
walletConnectRepository.setUserNamespaces(userNamespaces)
}
private fun handleDeeplinkStack(accounts: List<Account>) {
runCatching {
if (accounts.isEmpty()) return
isWalletConnectReadyForDeepLinks = true
val lastDeeplink = deeplinkStack.pop()
store.dispatchOnMain(WalletConnectAction.OpenSession(lastDeeplink))
}.onFailure {
Timber.e("WC deeplink handling failed. $it")
}
}
private suspend fun subscribeToEvents() {
events
.onEach { wcEvent ->
@ -167,6 +188,9 @@ class WalletConnectInteractor(
is WalletConnectEvents.SessionRequest -> {
handleRequest(wcEvent)
}
is WalletConnectEvents.PairConnectError -> {
handler.onPairConnectError(wcEvent.error)
}
}
}
.flowOn(dispatchers.io)
@ -329,6 +353,21 @@ class WalletConnectInteractor(
return uri.lowercase().startsWith(WC_SCHEME)
}
/**
* Handles Wallet Connect deep links.
* If wallet connect is able to handle the deeplink, session is started with deeplink.
* Otherwise, deeplink is stored until wallet connect is ready to handle it.
*
* @param deeplink deeplink to handle
*/
fun addDeeplink(deeplink: String) {
if (isWalletConnectReadyForDeepLinks) {
store.dispatchOnMain(WalletConnectAction.OpenSession(deeplink))
} else {
deeplinkStack.push(deeplink)
}
}
private fun getCardId(userWallet: UserWallet): String? {
return if (userWallet.scanResponse.card.backupStatus?.isActive != true) {
userWallet.cardId

View file

@ -26,4 +26,6 @@ sealed interface WalletConnectEvents {
val metaUrl: String,
val method: String,
) : WalletConnectEvents
data class PairConnectError(val error: Throwable) : WalletConnectEvents
}

View file

@ -22,17 +22,19 @@ sealed class WalletConnectAction : Action {
data class ShowClipboardOrScanQrDialog(val wcUri: String) : WalletConnectAction()
//region WalletConnect 2.0
object ApproveProposal : WalletConnectAction()
object RejectProposal : WalletConnectAction()
data object ApproveProposal : WalletConnectAction()
data object RejectProposal : WalletConnectAction()
object SessionEstablished : WalletConnectAction()
data object SessionEstablished : WalletConnectAction()
data class SessionRejected(val error: WalletConnectError) : WalletConnectAction()
data class SessionListUpdated(val sessions: List<WcSessionForScreen>) : WalletConnectAction()
data class ShowSessionRequest(val sessionRequest: WcPreparedRequest) : WalletConnectAction()
object RejectUnsupportedRequest : WalletConnectAction()
data object RejectUnsupportedRequest : WalletConnectAction()
data class PerformRequestedAction(val sessionRequest: WcPreparedRequest) : WalletConnectAction()
data class PairConnectErrorAction(val throwable: Throwable) : WalletConnectAction()
//endregion WalletConnect 2.0
}

View file

@ -49,8 +49,11 @@ class WalletConnectMiddleware {
when (action) {
is WalletConnectAction.HandleDeepLink -> {
if (!action.wcUri.isNullOrBlank()) {
store.dispatchOnMain(WalletConnectAction.OpenSession(action.wcUri))
val wsUrl = action.wcUri
Timber.i("WC deeplink: $wsUrl")
if (!wsUrl.isNullOrBlank()) {
Timber.i("WC deeplink added to stack: $wsUrl")
walletConnectInteractor.addDeeplink(wsUrl)
}
}
is WalletConnectAction.DisconnectSession -> {
@ -181,6 +184,9 @@ class WalletConnectMiddleware {
is WalletConnectAction.RejectUnsupportedRequest -> {
store.dispatchOnMain(GlobalAction.ShowDialog(WalletConnectDialog.UnsupportedNetwork()))
}
is WalletConnectAction.PairConnectErrorAction -> {
store.dispatch(GlobalAction.ShowDialog(WalletConnectDialog.PairConnectErrorDialog(action.throwable)))
}
}
}

View file

@ -14,6 +14,7 @@ object WalletConnectReducer {
is WalletConnectAction.RejectProposal,
is WalletConnectAction.SessionEstablished,
is WalletConnectAction.SessionRejected,
is WalletConnectAction.PairConnectErrorAction,
-> state.copy(loading = false)
is WalletConnectAction.SessionListUpdated -> state.copy(
wc2Sessions = action.sessions,

View file

@ -117,6 +117,8 @@ sealed class WalletConnectDialog : StateDialog {
data class SignTransactionDialog(
val data: WcPreparedRequest.SignTransaction,
) : WalletConnectDialog()
data class PairConnectErrorDialog(val error: Throwable) : WalletConnectDialog()
}
data class WcTransactionData(

View file

@ -483,7 +483,7 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
when (result) {
is CompletionResult.Success -> {
val backupValidator = BackupValidator()
if (!backupValidator.isValid(CardDTO(result.data))) {
if (!backupValidator.isValidBackupStatus(CardDTO(result.data))) {
store.dispatchOnMain(BackupAction.ErrorInBackupCard)
}
if (backupService.currentState == BackupService.State.Finished) {

View file

@ -397,7 +397,7 @@
<string name="onboarding_wallet_info_title_third">Код доступа</string>
<string name="organize_tokens_group">Группы</string>
<string name="organize_tokens_sort_by_balance">По балансу</string>
<string name="organize_tokens_title">Сортировка токенов</string>
<string name="organize_tokens_title">Упорядочить токены</string>
<string name="organize_tokens_ungroup">Список</string>
<string name="qr_scanner_camera_denied_gallery_button">Выбрать из галереи</string>
<string name="qr_scanner_camera_denied_settings_button">Настройки</string>
@ -670,7 +670,7 @@
<string name="wallet_settings_title">Настройки кошелька</string>
<string name="wallet_title">Tangem</string>
<string name="warning_access_denied_message">Используйте %s или отсканируйте карту, чтобы разблокировать доступ к вашему кошельку</string>
<string name="warning_backup_errors_message">Пожалуйста, выведите все средства из этого кошелька, сбросьте его к заводским настройкам и создайте новый. Доступ к текущему кошельку будет утерян.</string>
<string name="warning_backup_errors_message">Похоже, что процесс активации карт не был завершен корректно. Это могло быть вызвано проблемой взаимодействия с модулем NFC либо некорректным прикладыванием карты к телефону. Пожалуйста, обратитесь в нашу службу поддержки для уточнения деталей.</string>
<string name="warning_backup_errors_title">Ошибка активации</string>
<string name="warning_beacon_chain_retirement_content">По решению разработчиков сети BNB стандарт BEP-2 перестанет поддерживаться в июне 2024 года. Чтобы не потерять активы, их необходимо преобразовать в стандарт BEP-20. Используйте функцию обмена в приложении или сторонние сервисы, чтобы перевести средства в cеть BNB Smart Chain.</string>
<string name="warning_beacon_chain_retirement_title">Отключение сети BNB Beacon Chain</string>

View file

@ -663,7 +663,7 @@
<string name="wallet_settings_title">Wallet settings</string>
<string name="wallet_title">Tangem</string>
<string name="warning_access_denied_message">Use %s or scan a card to unlock access to your wallet</string>
<string name="warning_backup_errors_message">Please withdraw all funds from this wallet, reset it to factory settings, and create a new one. Access to the current wallet will be lost.</string>
<string name="warning_backup_errors_message">It seems that the card activation was not completed correctly. This could be due to an issue with your device\'s NFC module or incorrect tapping of the card to your device. Please contact our Support team for assistance.</string>
<string name="warning_backup_errors_title">Activation error</string>
<string name="warning_beacon_chain_retirement_content">According to BNB network developers, support for the BEP-2 standard will end in June 2024. To avoid losing assets with this standard, please convert them to the BEP-20 standard. Use our swap service or third-party services to transfer funds to the BNB Smart Chain network.</string>
<string name="warning_beacon_chain_retirement_title">BNB Beacon Chain will shut down</string>

View file

@ -8,7 +8,9 @@ import java.math.BigDecimal
sealed interface LegacyAction : Action {
object SendEmailRateCanBeBetter : LegacyAction
data object SendEmailSupport : LegacyAction
data object SendEmailRateCanBeBetter : LegacyAction
/**
* Initiate an onboarding process.

View file

@ -7,10 +7,14 @@ import javax.inject.Inject
class BackupValidator @Inject constructor() {
fun isValid(cardDTO: CardDTO): Boolean {
fun isValidFull(cardDTO: CardDTO): Boolean {
return validateBackupStatus(cardDTO) && validateCurves(cardDTO)
}
fun isValidBackupStatus(cardDTO: CardDTO): Boolean {
return validateBackupStatus(cardDTO)
}
private fun validateCurves(cardDTO: CardDTO): Boolean {
val config = CardConfig.createConfig(cardDTO)
// / Since the curve `bls12381_G2_AUG` was added later into first generation of wallets,

View file

@ -58,7 +58,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
buildList {
addSwapPromoNotification(shouldShowPromo, promoBanner, clickIntents)
addCriticalNotifications(userWallet)
addCriticalNotifications(userWallet, clickIntents)
addInformationalNotifications(cardTypesResolver, maybeTokenList, clickIntents)
@ -86,11 +86,14 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
)
}
private fun MutableList<WalletNotification>.addCriticalNotifications(userWallet: UserWallet) {
private fun MutableList<WalletNotification>.addCriticalNotifications(
userWallet: UserWallet,
clickIntents: WalletClickIntents,
) {
val cardTypesResolver = userWallet.scanResponse.cardTypesResolver
addIf(
element = WalletNotification.Critical.BackupError,
condition = !backupValidator.isValid(userWallet.scanResponse.card) || userWallet.hasBackupError,
element = WalletNotification.Critical.BackupError { clickIntents.onSupportClick() },
condition = !backupValidator.isValidBackupStatus(userWallet.scanResponse.card) || userWallet.hasBackupError,
)
addIf(

View file

@ -20,11 +20,16 @@ import org.joda.time.DateTime
@Immutable
sealed class WalletNotification(val config: NotificationConfig) {
sealed class Critical(title: TextReference, subtitle: TextReference) : WalletNotification(
sealed class Critical(
title: TextReference,
subtitle: TextReference,
buttonsState: NotificationConfig.ButtonsState? = null,
) : WalletNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = R.drawable.ic_alert_circle_24,
buttonsState = buttonsState,
),
) {
@ -38,9 +43,13 @@ sealed class WalletNotification(val config: NotificationConfig) {
subtitle = resourceReference(id = R.string.warning_failed_to_verify_card_message),
)
data object BackupError : Critical(
data class BackupError(val onSupportClick: () -> Unit) : Critical(
title = resourceReference(R.string.warning_backup_errors_title),
subtitle = resourceReference(R.string.warning_backup_errors_message),
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(id = R.string.details_row_title_contact_to_support),
onClick = onSupportClick,
),
)
}

View file

@ -62,6 +62,8 @@ internal interface WalletWarningsClickIntents {
fun onTravalaPromoClick(link: String?)
fun onCloseTravalaPromoClick()
fun onSupportClick()
}
@Suppress("LongParameterList")
@ -254,7 +256,11 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
}
}
private suspend fun getSelectedUserWallet(): UserWallet? {
override fun onSupportClick() {
reduxStateHolder.dispatch(LegacyAction.SendEmailSupport)
}
private fun getSelectedUserWallet(): UserWallet? {
val userWalletId = stateHolder.getSelectedWalletId()
return getUserWalletUseCase(userWalletId).getOrElse {
Timber.e(