Updated on 2026-08-14

This commit is contained in:
Tangem 2024-07-29 13:07:57 +03:00
commit b0d5ab72e1
50 changed files with 378 additions and 137 deletions

View file

@ -20,6 +20,7 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />

View file

@ -2,13 +2,10 @@ package com.tangem.tap
import android.Manifest
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.nfc.NfcAdapter
import android.os.Build
import android.os.Bundle
import android.view.View
@ -298,31 +295,12 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
override fun onResume() {
super.onResume()
val nfcAdapter = NfcAdapter.getDefaultAdapter(this)
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
)
val intentFilters = arrayOf(
IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED),
IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED),
IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED),
)
nfcAdapter.enableForegroundDispatch(this, pendingIntent, intentFilters, null)
// TODO: RESEARCH! NotificationsHandler is created in onResume and destroyed in onStop
notificationsHandler = NotificationsHandler(binding.fragmentContainer)
navigateToInitialScreenIfNeeded(intent)
}
override fun onPause() {
super.onPause()
val nfcAdapter = NfcAdapter.getDefaultAdapter(this)
nfcAdapter.disableForegroundDispatch(this)
}
override fun onStop() {
notificationsHandler = null
dialogManager.onStop()

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)
@ -139,8 +144,9 @@ class DialogManager : StoreSubscriber<GlobalState> {
title = context.getString(state.dialog.titleRes, state.dialog.currencySymbol),
message = context.getString(
state.dialog.messageRes,
state.dialog.currencySymbol,
state.dialog.currencyTitle,
state.dialog.currencySymbol,
state.dialog.networkName,
),
context = context,
)

View file

@ -43,6 +43,7 @@ sealed class AppDialog : StateDialog {
data class TokensAreLinkedDialog(
val currencyTitle: String,
val currencySymbol: String,
val networkName: String,
) : AppDialog() {
val messageRes: Int = R.string.token_details_unable_hide_alert_message
val titleRes: Int = R.string.token_details_unable_hide_alert_title

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

@ -30,7 +30,7 @@ private fun internalReduce(action: Action, state: AppState): NavigationState {
when {
// Destroy the activity if it invoked for the same activity. Prevents overwriting to null if there is a
// new scan from the background [REDACTED_TASK_KEY]
navState.activity?.get() == navigationAction.activity.get() -> navState.copy(activity = null)
navState.activity?.get() == navigationAction.activity.get() -> NavigationState()
else -> navState
}
}

View file

@ -19,6 +19,8 @@ import com.tangem.sdk.extensions.*
import com.tangem.sdk.nfc.NfcManager
import com.tangem.sdk.storage.create
import com.tangem.tap.foregroundActivityObserver
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
import javax.inject.Singleton
@ -30,6 +32,7 @@ import javax.inject.Singleton
@Singleton
internal class DefaultCardSdkProvider @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val dispatchers: CoroutineDispatcherProvider,
) : CardSdkProvider, CardSdkOwner {
private val observer = Observer()
@ -39,12 +42,12 @@ internal class DefaultCardSdkProvider @Inject constructor(
override val sdk: TangemSdk
get() = holder?.sdk ?: tryToRegisterWithForegroundActivity()
override fun register(activity: FragmentActivity) {
override fun register(activity: FragmentActivity) = runBlocking(dispatchers.mainImmediate) {
if (activity.isDestroyed || activity.isFinishing || activity.isChangingConfigurations) {
val message = "Tangem SDK owner registration skipped: activity is destroyed or finishing"
analyticsEventHandler.send(TangemSdkWarningEvent(message))
Log.info { message }
return
return@runBlocking
}
if (holder != null) {
@ -58,7 +61,7 @@ internal class DefaultCardSdkProvider @Inject constructor(
Log.info { "Tangem SDK owner registered" }
}
private fun tryToRegisterWithForegroundActivity(): TangemSdk {
private fun tryToRegisterWithForegroundActivity(): TangemSdk = runBlocking(dispatchers.mainImmediate) {
val warning = "Tangem SDK holder is null, trying to recreate it with foreground activity"
analyticsEventHandler.send(TangemSdkWarningEvent(warning))
Log.warning { warning }
@ -83,7 +86,7 @@ internal class DefaultCardSdkProvider @Inject constructor(
error(error)
}
return sdk
return@runBlocking sdk
}
private fun initialize(activity: FragmentActivity) {

View file

@ -33,6 +33,10 @@ interface TangemSdkManager {
val userCodeRequestPolicy: UserCodeRequestPolicy
suspend fun checkCanUseBiometry(awaitInitialization: Boolean = true): Boolean
suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean = true): Boolean
suspend fun scanProduct(
cardId: String? = null,
messageRes: Int? = null,

View file

@ -3,9 +3,11 @@ package com.tangem.tap.domain.sdk.impl
import android.content.res.Resources
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.tangem.Log
import com.tangem.Message
import com.tangem.TangemSdk
import com.tangem.common.*
import com.tangem.common.authentication.AuthenticationManager
import com.tangem.common.authentication.keystore.KeystoreManager
import com.tangem.common.card.FirmwareVersion
import com.tangem.common.core.*
@ -38,16 +40,21 @@ import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask
import com.tangem.tap.domain.twins.FinalizeTwinTask
import com.tangem.wallet.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LargeClass")
class DefaultTangemSdkManager(
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val resources: Resources,
) : TangemSdkManager {
private val awaitInitializationMutex = Mutex()
private val tangemSdk: TangemSdk
get() = cardSdkConfigRepository.sdk
@ -73,6 +80,42 @@ class DefaultTangemSdkManager(
override val userCodeRequestPolicy: UserCodeRequestPolicy
get() = tangemSdk.config.userCodeRequestPolicy
override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean {
return try {
needEnrollBiometrics
} catch (e: TangemSdkError.AuthenticationNotInitialized) {
Log.error {
"Trying to access `needEnrollBiometrics` flag when authentication manager is not initialized: " +
if (awaitInitialization) "awaiting initialization" else "failing"
}
if (awaitInitialization) {
awaitAuthenticationManagerInitialization().needEnrollBiometrics
} else {
throw e
}
}
}
override suspend fun checkCanUseBiometry(awaitInitialization: Boolean): Boolean {
return try {
canUseBiometry
} catch (e: TangemSdkError.AuthenticationNotInitialized) {
Log.error {
"Trying to access `canUseBiometry` flag when authentication manager is not initialized: " +
if (awaitInitialization) "awaiting initialization" else "failing"
}
if (awaitInitialization) {
val manager = awaitAuthenticationManagerInitialization()
manager.canAuthenticate || manager.needEnrollBiometrics
} else {
throw e
}
}
}
override suspend fun scanProduct(
cardId: String?,
messageRes: Int?,
@ -288,6 +331,26 @@ class DefaultTangemSdkManager(
tangemSdk.config.userCodeRequestPolicy = policy
}
private suspend fun awaitAuthenticationManagerInitialization(): AuthenticationManager {
return awaitInitializationMutex.withLock {
var attemps = 0
do {
if (tangemSdk.authenticationManager.isInitialized) {
break
} else {
if (attemps++ >= MAX_INITIALIZE_ATTEMPTS) {
error("Can't initialize authentication manager after $MAX_INITIALIZE_ATTEMPTS attempts")
} else {
delay(timeMillis = 200)
}
}
} while (true)
tangemSdk.authenticationManager
}
}
// region Twin-specific
override suspend fun createFirstTwinWallet(
@ -335,6 +398,8 @@ class DefaultTangemSdkManager(
// endregion
companion object {
private const val MAX_INITIALIZE_ATTEMPTS = 10
@Deprecated("Use [DefaultCardSdkProvider] instead")
val config = Config(
linkedTerminal = true,

View file

@ -28,9 +28,11 @@ class MockTangemSdkManager(
private val resources: Resources,
) : TangemSdkManager {
override val canUseBiometry = false
private var userCodeRequestPolicyInternal: UserCodeRequestPolicy = UserCodeRequestPolicy.Default
override val needEnrollBiometrics = false
override val canUseBiometry: Boolean = false
override val needEnrollBiometrics: Boolean = false
override val keystoreManager = DummyKeystoreManager()
@ -39,7 +41,9 @@ class MockTangemSdkManager(
override val userCodeRequestPolicy: UserCodeRequestPolicy
get() = userCodeRequestPolicyInternal
private var userCodeRequestPolicyInternal: UserCodeRequestPolicy = UserCodeRequestPolicy.Default
override suspend fun checkCanUseBiometry(awaitInitialization: Boolean): Boolean = canUseBiometry
override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean = needEnrollBiometrics
override suspend fun scanProduct(
cardId: String?,

View file

@ -7,5 +7,5 @@ internal class DefaultLegacySettingsRepository(
private val tangemSdkManager: TangemSdkManager,
) : LegacySettingsRepository {
override fun canUseBiometry(): Boolean = tangemSdkManager.canUseBiometry
override suspend fun canUseBiometry(): Boolean = tangemSdkManager.checkCanUseBiometry()
}

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

@ -35,7 +35,8 @@ internal class DefaultLegacyWalletConnectRepository(
private val _activeSessions: MutableSharedFlow<List<WalletConnectSession>> = MutableSharedFlow()
override val activeSessions: Flow<List<WalletConnectSession>> = _activeSessions
private var currentSessions: List<WalletConnectSession> = emptyList()
override var currentSessions: List<WalletConnectSession> = emptyList()
private set
/**
* @param projectId Project ID at https://cloud.walletconnect.com/
@ -246,7 +247,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

@ -9,6 +9,8 @@ interface LegacyWalletConnectRepository {
val activeSessions: Flow<List<WalletConnectSession>>
val currentSessions: List<WalletConnectSession>
fun init(projectId: String)
fun setUserNamespaces(userNamespaces: Map<NetworkNamespace, List<Account>>)

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,18 @@ class WalletConnectInteractor(
walletConnectRepository.setUserNamespaces(userNamespaces)
}
private fun handleDeeplinkStack(accounts: List<Account>) {
runCatching {
if (accounts.isEmpty()) return
isWalletConnectReadyForDeepLinks = true
if (deeplinkStack.empty()) return
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 +189,9 @@ class WalletConnectInteractor(
is WalletConnectEvents.SessionRequest -> {
handleRequest(wcEvent)
}
is WalletConnectEvents.PairConnectError -> {
handler.onPairConnectError(wcEvent.error)
}
}
}
.flowOn(dispatchers.io)
@ -329,6 +354,34 @@ 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) {
val deeplinkRegex = Regex(WC_PARAM_REGEX)
val matched = deeplinkRegex.findAll(deeplink)
val sessionTopic = matched.firstOrNull { it.value.contains(WC_TOPIC_QUERY_NAME) }?.groupValues?.lastOrNull()
val isAlreadyActiveSessionTopic = walletConnectRepository.currentSessions.any { session ->
session.topic == sessionTopic
}
if (isAlreadyActiveSessionTopic && sessionTopic != null) {
Timber.i("WC already has an active session topic: $deeplink")
return
}
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
@ -367,7 +420,9 @@ class WalletConnectInteractor(
return sessionRequestConverter.prepareRequest(sessionRequest, userWalletId)
}
companion object {
private const val WC_SCHEME = "wc"
private companion object {
const val WC_SCHEME = "wc"
const val WC_TOPIC_QUERY_NAME = "sessionTopic"
const val WC_PARAM_REGEX = "([a-zA-Z\\d-]+)=([a-zA-Z\\d]+)"
}
}

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

@ -37,7 +37,10 @@ import com.tangem.utils.coroutines.saveIn
import com.tangem.wallet.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.rekotlin.Action
@ -265,7 +268,6 @@ class DetailsMiddleware {
.onEach { needEnrollBiometrics ->
store.dispatchWithMain(DetailsAction.AppSettings.BiometricsStatusChanged(needEnrollBiometrics))
}
.flowOn(Dispatchers.IO)
.launchIn(lifecycleScope)
.saveIn(checkBiometricsStatusJobHolder)
}

View file

@ -76,7 +76,9 @@ private fun handlePrepareScreen(action: DetailsAction.PrepareScreen, state: AppS
},
createBackupAllowed = action.scanResponse.card.backupStatus == CardDTO.BackupStatus.NoBackup,
appSettingsState = AppSettingsState(
isBiometricsAvailable = tangemSdkManager.canUseBiometry,
isBiometricsAvailable = runBlocking {
tangemSdkManager.checkCanUseBiometry()
},
saveWallets = action.shouldSaveUserWallets,
saveAccessCodes = runBlocking {
store.inject(DaggerGraphState::settingsRepository).shouldSaveAccessCodes()

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

@ -99,7 +99,7 @@ object OnboardingHelper {
}
// When should not save user wallets but device has biometry and save wallet screen has not been shown,
// then open save wallet screen
tangemSdkManager.canUseBiometry && settingsRepository.shouldShowSaveUserWalletScreen() -> {
tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen() -> {
proceedWithScanResponse(scanResponse, backupCardsIds, hasBackupError)
delay(timeMillis = 1_200)

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

@ -91,10 +91,10 @@ internal class SaveWalletMiddleware {
store.dispatchOnMain(NavigationAction.OpenBiometricsSettings)
}
private fun allowToUseBiometrics(state: SaveWalletState) {
if (tangemSdkManager.needEnrollBiometrics) {
store.dispatchOnMain(SaveWalletAction.EnrollBiometrics)
return
private fun allowToUseBiometrics(state: SaveWalletState) = scope.launch {
if (tangemSdkManager.checkNeedEnrollBiometrics()) {
store.dispatchWithMain(SaveWalletAction.EnrollBiometrics)
return@launch
}
if (state.backupInfo != null) {
@ -104,24 +104,22 @@ internal class SaveWalletMiddleware {
Analytics.send(MainScreen.EnableBiometrics(AnalyticsParam.OnOffState.On))
}
scope.launch {
/*
/*
* because it will be automatically saved on UserWalletsListManager switch
*/
val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager)
val selectedUserWallet = userWalletsListManager.selectedUserWalletSync.guard {
val error = IllegalStateException("No selected user wallet")
Timber.e(error, "Unable to save user wallet")
store.dispatchWithMain(
SaveWalletAction.AllowToUseBiometrics.Error(TangemSdkError.ExceptionError(error)),
)
return@launch
}
* because it will be automatically saved on UserWalletsListManager switch
*/
val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager)
val selectedUserWallet = userWalletsListManager.selectedUserWalletSync.guard {
val error = IllegalStateException("No selected user wallet")
Timber.e(error, "Unable to save user wallet")
store.dispatchWithMain(
SaveWalletAction.AllowToUseBiometrics.Error(TangemSdkError.ExceptionError(error)),
)
return@launch
}
handleSuccessAllowing(selectedUserWallet)
}.saveIn(saveWalletJobHolder)
}
handleSuccessAllowing(selectedUserWallet)
}.saveIn(saveWalletJobHolder)
private suspend fun handleSuccessAllowing(userWallet: UserWallet) {
store.inject(DaggerGraphState::walletsRepository).saveShouldSaveUserWallets(item = true)

View file

@ -29,9 +29,13 @@ internal class DefaultTokensListRouter : TokensListRouter {
store.dispatchNotification(R.string.contract_address_copied_message)
}
override fun openUnableHideMainTokenAlert(tokenName: String, tokenSymbol: String) {
override fun openUnableHideMainTokenAlert(tokenName: String, tokenSymbol: String, networkName: String) {
store.dispatchDialogShow(
dialog = AppDialog.TokensAreLinkedDialog(currencyTitle = tokenName, currencySymbol = tokenSymbol),
dialog = AppDialog.TokensAreLinkedDialog(
currencyTitle = tokenName,
currencySymbol = tokenSymbol,
networkName = networkName,
),
)
}

View file

@ -19,8 +19,9 @@ internal interface TokensListRouter {
*
* @param tokenName token name
* @param tokenSymbol token brief name
* @param networkName blockchain network full name
*/
fun openUnableHideMainTokenAlert(tokenName: String, tokenSymbol: String)
fun openUnableHideMainTokenAlert(tokenName: String, tokenSymbol: String, networkName: String)
/**
* Open alert to remove wallet

View file

@ -365,6 +365,7 @@ internal class TokensListViewModel @Inject constructor(
router.openUnableHideMainTokenAlert(
tokenName = blockchain.name,
tokenSymbol = blockchain.currency,
networkName = blockchain.fullName,
)
} else if (isAddedOnMainScreen) {
router.openRemoveWalletAlert(

View file

@ -5,6 +5,7 @@ import okio.Timeout
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import timber.log.Timber
internal class ApiResponseCallDelegate<T : Any>(
private val wrappedCall: Call<T>,
@ -20,7 +21,9 @@ internal class ApiResponseCallDelegate<T : Any>(
override fun timeout(): Timeout = wrappedCall.timeout()
override fun isExecuted(): Boolean = wrappedCall.isExecuted
override fun isCanceled(): Boolean = wrappedCall.isCanceled
override fun cancel() { wrappedCall.cancel() }
override fun cancel() {
wrappedCall.cancel()
}
private inner class ApiResponseCallback(
private val responseCallback: Callback<ApiResponse<T>>,
@ -33,7 +36,15 @@ internal class ApiResponseCallDelegate<T : Any>(
}
override fun onFailure(call: Call<T>, t: Throwable) {
val error = t.toApiError()
val error = try {
t.toApiError()
} catch (e: ApiResponseError) {
Timber.e(e, "error map toApiError")
e
} catch (e: Exception) {
Timber.e(e, "onFailure UnknownException")
ApiResponseError.UnknownException(e)
}
val safeResponse = apiError<T>(error)
responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse))

View file

@ -73,10 +73,16 @@ sealed class ApiResponseError : Exception() {
}
/** Represents a network error, typically when there's no connectivity. */
data object NetworkException : ApiResponseError()
@Suppress("UnusedPrivateMember")
data object NetworkException : ApiResponseError() {
private fun readResolve(): Any = NetworkException
}
/** Represents a timeout error, typically when the server takes too long to respond. */
data object TimeoutException : ApiResponseError()
@Suppress("UnusedPrivateMember")
data object TimeoutException : ApiResponseError() {
private fun readResolve(): Any = TimeoutException
}
/**
* Represents an unexpected exception that doesn't fall into one of the other categories.

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.api.common.response
import kotlinx.coroutines.TimeoutCancellationException
import retrofit2.Response
import timber.log.Timber
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@ -16,10 +17,15 @@ internal fun <T : Any> Response<T>.toSafeApiResponse(): ApiResponse<T> {
} else {
val code = ApiResponseError.HttpException.Code.values
.firstOrNull { it.code == code() }
val e = if (code == null) {
ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}"))
} else {
ApiResponseError.HttpException(code, message(), errorBody()?.string())
val e = try {
if (code == null) {
ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}"))
} else {
ApiResponseError.HttpException(code, message(), errorBody()?.string())
}
} catch (e: Exception) {
Timber.e(e, "UnknownException occured")
ApiResponseError.UnknownException(e)
}
apiError(e)

View file

@ -673,7 +673,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

@ -666,7 +666,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

@ -172,7 +172,7 @@ private class NotificationWithBackgroundPreviewProvider : PreviewParameterProvid
title = resourceReference(id = R.string.main_swap_changelly_promotion_title),
subtitle = resourceReference(
id = R.string.main_swap_changelly_promotion_message,
formatArgs = wrappedList("1", "2"),
formatArgs = wrappedList("1", "2", "3"),
),
iconResId = R.drawable.img_swap_promo,
backgroundResId = R.drawable.img_swap_promo_blue_banner_background,
@ -180,7 +180,7 @@ private class NotificationWithBackgroundPreviewProvider : PreviewParameterProvid
NotificationConfig(
title = resourceReference(
id = R.string.token_swap_changelly_promotion_title,
formatArgs = wrappedList("1", "2"),
formatArgs = wrappedList("1"),
),
subtitle = stringReference(
"Swap multiple currencies between any chains you wish. Swap multiple " +

View file

@ -82,7 +82,9 @@ private fun LazyListScope.contentItems(
when (item) {
is TxHistoryState.TxHistoryItemState.GroupTitle -> item.itemKey
is TxHistoryState.TxHistoryItemState.Title -> item.onExploreClick.hashCode()
is TxHistoryState.TxHistoryItemState.Transaction -> item.state.txHash
is TxHistoryState.TxHistoryItemState.Transaction ->
item.state.txHash +
((item.state as? TransactionState.Content)?.hashCode() ?: "")
}
},
contentType = txHistoryItems.itemContentType { it::class.java },

View file

@ -52,19 +52,23 @@ internal class CoinsPagingSource(
suffix = CryptoCurrency.ID.Suffix.RawID(coin.id),
)
}
val quotes = quotesRepository.getQuotesSync(currenciesIds = coinsIds.toSet(), refresh = false)
LoadResult.Page(
data = CoinsResponseConverter.convert(
CoinsData(
response.coins,
response.imageHost,
quotes,
try {
val quotes = quotesRepository.getQuotesSync(currenciesIds = coinsIds.toSet(), refresh = false)
LoadResult.Page(
data = CoinsResponseConverter.convert(
CoinsData(
response.coins,
response.imageHost,
quotes,
),
),
),
prevKey = if (page == 0) null else page.minus(other = 1),
nextKey = if (response.coins.isEmpty()) null else page.plus(other = 1),
)
prevKey = if (page == 0) null else page.minus(other = 1),
nextKey = if (response.coins.isEmpty()) null else page.plus(other = 1),
)
} catch (t: Throwable) {
LoadResult.Error(t)
}
},
onFailure = { LoadResult.Error(it) },
)

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

@ -4,5 +4,5 @@ import com.tangem.domain.settings.repositories.LegacySettingsRepository
class CanUseBiometryUseCase(private val legacySettingsRepository: LegacySettingsRepository) {
operator fun invoke(): Boolean = legacySettingsRepository.canUseBiometry()
suspend operator fun invoke(): Boolean = legacySettingsRepository.canUseBiometry()
}

View file

@ -2,5 +2,5 @@ package com.tangem.domain.settings.repositories
interface LegacySettingsRepository {
fun canUseBiometry(): Boolean
suspend fun canUseBiometry(): Boolean
}

View file

@ -23,12 +23,8 @@ platform :android do
desc "Runs all the tests"
lane :test do
gradle(
task: "clean assemble",
build_type: "Debug"
)
gradle(task: "detekt")
gradle(task: "test")
gradle(task: "testDebugUnitTest")
end
desc "Build a signed release APK"

View file

@ -1949,13 +1949,21 @@ internal class SwapInteractorImpl @Inject constructor(
}
private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map<CryptoCurrency.ID, Quote> {
val set = quotesRepository.getQuotesSync(ids.toSet(), false)
val set = ids.toSet().getQuotesOrEmpty(false)
return ids
.mapNotNull { id -> set.find { it.rawCurrencyId == id.rawCurrencyId }?.let { id to it } }
.toMap()
}
private suspend fun Set<CryptoCurrency.ID>.getQuotesOrEmpty(refresh: Boolean): Set<Quote> {
return try {
quotesRepository.getQuotesSync(this, refresh)
} catch (t: Throwable) {
emptySet()
}
}
private fun getDemoFees(cryptoCurrency: CryptoCurrency): ProxyFees.MultipleFees {
val demoFee = ProxyAmount(
currencySymbol = cryptoCurrency.symbol,

View file

@ -73,7 +73,8 @@ internal class ExchangeStatusFactory(
.map { savedTransactions ->
val quotes = savedTransactions
?.flatMap { setOf(it.fromCryptoCurrency.id, it.toCryptoCurrency.id) }
?.let { quotesRepository.getQuotesSync(it.toSet(), true) }
?.toSet()
?.getQuotesOrEmpty(true)
?: emptySet()
getExchangeStatusState(
@ -216,4 +217,12 @@ internal class ExchangeStatusFactory(
else -> null
}
}
private suspend fun Set<CryptoCurrency.ID>.getQuotesOrEmpty(refresh: Boolean): Set<Quote> {
return try {
quotesRepository.getQuotesSync(this, refresh)
} catch (t: Throwable) {
emptySet()
}
}
}

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(

View file

@ -87,9 +87,9 @@ markdown = "0.7.2"
# endregion Other libraries
# region Tangem
tangemBlockchainSdk = "release-app_5.12-698"
tangemBlockchainSdk = "release-app_5.12-701"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "release-app_5.12-369"
tangemCardSdk = "release-app_5.12-373"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
# endregion Tangem