Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-31 16:35:03 +05:00
parent c5fc1a4464
commit ded9de148c
17 changed files with 183 additions and 156 deletions

View file

@ -91,8 +91,6 @@ class DialogManager : StoreSubscriber<GlobalState> {
context = context,
)
}
is WalletConnectDialog.ClipboardOrScanQr ->
ClipboardOrScanQrDialog.create(state.dialog.clipboardUri, context)
is WalletConnectDialog.RequestTransaction -> TransactionDialog.create(state.dialog.data, context)
is WalletConnectDialog.PersonalSign -> PersonalSignDialog.create(state.dialog.data, context)
is WalletConnectDialog.BnbTransactionDialog ->

View file

@ -368,10 +368,6 @@ class WalletConnectInteractor(
}
}
fun isWalletConnectUri(uri: String): Boolean {
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.
@ -436,7 +432,6 @@ class WalletConnectInteractor(
}
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

@ -9,9 +9,7 @@ import org.rekotlin.Action
sealed class WalletConnectAction : Action {
data class HandleDeepLink(val wcUri: String?) : WalletConnectAction()
data class StartWalletConnect(
val copiedUri: String?,
) : WalletConnectAction()
data object StartWalletConnect : WalletConnectAction()
data class OpenSession(
val wcUri: String,
@ -24,7 +22,6 @@ sealed class WalletConnectAction : Action {
data class RejectRequest(val topic: String, val id: Long) : WalletConnectAction()
data class ShowClipboardOrScanQrDialog(val wcUri: String) : WalletConnectAction()
//region WalletConnect 2.0
data class ApproveProposal(val proposal: WalletConnectEvents.SessionProposal) : WalletConnectAction()
data object RejectProposal : WalletConnectAction()

View file

@ -56,26 +56,10 @@ class WalletConnectMiddleware {
walletConnectInteractor.disconnectSession(action.topic)
}
is WalletConnectAction.StartWalletConnect -> {
val uri = action.copiedUri
if (uri != null && isWalletConnectUri(uri)) {
store.dispatchOnMain(WalletConnectAction.ShowClipboardOrScanQrDialog(uri))
} else {
store.dispatchNavigationAction {
push(
AppRoute.QrScanning(AppRoute.QrScanning.Source.WALLET_CONNECT),
)
}
store.dispatchNavigationAction {
push(AppRoute.QrScanning(AppRoute.QrScanning.Source.WalletConnect))
}
}
is WalletConnectAction.ShowClipboardOrScanQrDialog -> {
store.dispatchOnMain(
GlobalAction.ShowDialog(
WalletConnectDialog.ClipboardOrScanQr(
action.wcUri,
),
),
)
}
is WalletConnectAction.OpenSession -> {
val index = action.wcUri.indexOf("@")
when (action.wcUri[index + 1]) {
@ -200,8 +184,4 @@ class WalletConnectMiddleware {
return walletManagerFacade.getStoredWalletManagers(userWallet.walletId)
}
private fun isWalletConnectUri(uri: String): Boolean {
return walletConnectInteractor.isWalletConnectUri(uri)
}
}

View file

@ -83,7 +83,6 @@ data class WalletForSession(
sealed class WalletConnectDialog : StateDialog {
data object UnsupportedWcVersion : WalletConnectDialog()
data class ClipboardOrScanQr(val clipboardUri: String) : WalletConnectDialog()
data object UnsupportedCard : WalletConnectDialog()
data class UnsupportedNetwork(val networks: List<String>? = null) : WalletConnectDialog()
data object UnsupportedDapp : WalletConnectDialog()

View file

@ -4,7 +4,6 @@ import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction
@ -23,7 +22,6 @@ import javax.inject.Inject
internal class WalletConnectModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
private val clipboardManager: ClipboardManager,
) : Model() {
init {
@ -43,9 +41,7 @@ internal class WalletConnectModel @Inject constructor(
isLoading = state.loading,
onRemoveSession = { sessionUri -> onRemoveSession(sessionUri, sessions) },
onAddSession = {
store.dispatch(
WalletConnectAction.StartWalletConnect(copiedUri = clipboardManager.getText()),
)
store.dispatch(WalletConnectAction.StartWalletConnect)
},
)
}

View file

@ -1,32 +0,0 @@
package com.tangem.tap.features.details.ui.walletconnect.dialogs
import android.content.Context
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.tangem.common.routing.AppRoute
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction
import com.tangem.tap.store
import com.tangem.wallet.R
object ClipboardOrScanQrDialog {
fun create(wcUri: String, context: Context): AlertDialog {
return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply {
setTitle(context.getString(R.string.common_select_action))
setMessage(context.getText(R.string.wallet_connect_clipboard_alert))
setPositiveButton(context.getText(R.string.wallet_connect_paste_from_clipboard)) { _, _ ->
store.dispatch(WalletConnectAction.OpenSession(wcUri, WalletConnectAction.OpenSession.SourceType.ETC))
}
setNegativeButton(context.getText(R.string.wallet_connect_scan_new_code)) { _, _ ->
store.dispatchNavigationAction {
push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.WALLET_CONNECT))
}
}
setOnDismissListener {
store.dispatch(GlobalAction.HideDialog)
}
}.create()
}
}

View file

@ -318,14 +318,14 @@ internal class ChildFactory @Inject constructor(
}
is AppRoute.QrScanning -> {
val source = when (route.source) {
AppRoute.QrScanning.Source.SEND -> SourceType.SEND
AppRoute.QrScanning.Source.WALLET_CONNECT -> SourceType.WALLET_CONNECT
is AppRoute.QrScanning.Source.Send -> SourceType.SEND
is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT
}
createComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = QrScanningComponent.Params(
source = source,
networkName = route.networkName,
networkName = (route.source as? AppRoute.QrScanning.Source.Send)?.networkName,
),
componentFactory = qrScanningComponentFactory,
)
@ -536,14 +536,14 @@ internal class ChildFactory @Inject constructor(
}
is AppRoute.QrScanning -> {
val source = when (route.source) {
AppRoute.QrScanning.Source.SEND -> SourceType.SEND
AppRoute.QrScanning.Source.WALLET_CONNECT -> SourceType.WALLET_CONNECT
is AppRoute.QrScanning.Source.Send -> SourceType.SEND
is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT
}
route.asComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = QrScanningComponent.Params(
source = source,
networkName = route.networkName,
networkName = (route.source as? AppRoute.QrScanning.Source.Send)?.networkName,
),
componentFactory = qrScanningComponentFactory,
)

View file

@ -145,14 +145,19 @@ sealed class AppRoute(val path: String) : Route {
data object WalletConnectSessions : AppRoute(path = "/wallet_connect_sessions")
@Serializable
data class QrScanning(
val source: Source,
val networkName: String? = null,
) : AppRoute(path = "/$source/qr_scanning${if (networkName != null) "/$networkName" else ""}") {
data class QrScanning(val source: Source) : AppRoute(path = "/$source/qr_scanning${source.path}") {
enum class Source {
WALLET_CONNECT,
SEND,
@Serializable
sealed class Source {
val path: String
get() = when (this) {
is Send -> "/$networkName"
WalletConnect -> ""
}
data class Send(val networkName: String) : Source()
data object WalletConnect : Source()
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.feature.qrscanning.QrScanningComponent
@ -34,6 +35,7 @@ internal class QrScanningModel @Inject constructor(
private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase,
private val settingsManager: SettingsManager,
private val appRouter: AppRouter,
private val clipboardManager: ClipboardManager,
) : Model(), QrScanningClickIntents {
private val params = paramsContainer.require<QrScanningComponent.Params>()
@ -49,7 +51,14 @@ internal class QrScanningModel @Inject constructor(
// samsung for some reason disables reader mode, and then it works unstable
// to prevent this disable ir manually before scan QR
cardSdkProvider.sdk.forceDisableReaderMode()
stateHolder.update(InitializeQrScanningStateTransformer(this, params.source, params.networkName))
stateHolder.update(
InitializeQrScanningStateTransformer(
clickIntents = this,
clipboardManager = clipboardManager,
source = params.source,
network = params.networkName,
),
)
}
fun onCameraDeniedState() {

View file

@ -6,9 +6,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@ -22,11 +20,16 @@ import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.TopAppBarButton
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.qrscanning.impl.R
@ -57,6 +60,7 @@ internal fun QrScanningContent(
)
Overlay(
message = uiState.message,
pasteAction = uiState.pasteAction,
)
TangemTopAppBar(
@ -97,69 +101,100 @@ internal fun QrScanningContent(
}
}
@Suppress("LongMethod")
@Composable
private fun Overlay(message: TextReference?, overlayColor: Color = TangemColorPalette.Black.copy(alpha = 0.7f)) {
private fun Overlay(
message: TextReference?,
pasteAction: PasteAction,
overlayColor: Color = TangemColorPalette.Black.copy(alpha = 0.7f),
) {
val whiteColor = TangemTheme.colors.text.constantWhite
val borderWidth = with(LocalDensity.current) { TangemTheme.dimens.spacing4.toPx() }
val borderLength = with(LocalDensity.current) { TangemTheme.dimens.size20.toPx() }
val textMeasurer = rememberTextMeasurer()
val textPadding = with(LocalDensity.current) { TangemTheme.dimens.spacing24.toPx() }
val style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Center)
val textPadding = with(LocalDensity.current) { TangemTheme.dimens.spacing48.toPx() }
val style = TangemTheme.typography.body1.copy(textAlign = TextAlign.Center)
val text = message?.resolveReference()
Canvas(modifier = Modifier.fillMaxSize()) {
val squareSize = size.minDimension * QR_CODE_SIZE
val verticalOffset = (size.height - squareSize) / 2f
val horizontalOffset = (size.width - squareSize) / 2f
drawRect(
color = overlayColor,
topLeft = Offset.Zero,
size = Size(size.width, verticalOffset),
)
drawRect(
color = overlayColor,
topLeft = Offset(0f, verticalOffset),
size = Size(horizontalOffset, squareSize),
)
drawRect(
color = overlayColor,
topLeft = Offset(horizontalOffset + squareSize, verticalOffset),
size = Size(horizontalOffset, squareSize),
)
drawRect(
color = overlayColor,
topLeft = Offset(0f, verticalOffset + squareSize),
size = Size(size.width, verticalOffset),
)
drawRect(
color = whiteColor,
topLeft = Offset(horizontalOffset, verticalOffset),
size = Size(squareSize, squareSize),
style = Stroke(
width = borderWidth,
join = StrokeJoin.Round,
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(2 * borderLength, squareSize - 2 * borderLength),
phase = borderLength,
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val minDimension = minOf(this.constraints.minWidth, this.constraints.minHeight)
val squareSize = minDimension * QR_CODE_SIZE
val squareSizeInDp = with(LocalDensity.current) { squareSize.toDp() }
val verticalOffset = (constraints.minHeight - squareSize) / 2f
val horizontalOffset = (constraints.minWidth - squareSize) / 2f
Canvas(modifier = Modifier.fillMaxSize()) {
drawRect(
color = overlayColor,
topLeft = Offset.Zero,
size = Size(size.width, verticalOffset),
)
drawRect(
color = overlayColor,
topLeft = Offset(0f, verticalOffset),
size = Size(horizontalOffset, squareSize),
)
drawRect(
color = overlayColor,
topLeft = Offset(horizontalOffset + squareSize, verticalOffset),
size = Size(horizontalOffset, squareSize),
)
drawRect(
color = overlayColor,
topLeft = Offset(0f, verticalOffset + squareSize),
size = Size(size.width, verticalOffset),
)
drawRect(
color = whiteColor,
topLeft = Offset(horizontalOffset, verticalOffset),
size = Size(squareSize, squareSize),
style = Stroke(
width = borderWidth,
join = StrokeJoin.Round,
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(2 * borderLength, squareSize - 2 * borderLength),
phase = borderLength,
),
),
),
)
text?.let {
val textLayoutResult = text.let {
textMeasurer.measure(
text = it,
style = style,
constraints = Constraints.fixedWidth(squareSize.roundToInt()),
)
text?.let {
val textLayoutResult = text.let {
textMeasurer.measure(
text = it,
style = style,
constraints = Constraints.fixedWidth(squareSize.roundToInt()),
)
}
drawText(
textLayoutResult = textLayoutResult,
color = whiteColor,
topLeft = Offset(
x = horizontalOffset,
y = verticalOffset - textPadding - textLayoutResult.size.height,
),
)
}
drawText(
textLayoutResult = textLayoutResult,
color = whiteColor,
topLeft = Offset(
x = horizontalOffset,
y = verticalOffset + squareSize + textPadding,
),
}
if (pasteAction is PasteAction.Perform) {
TangemButton(
modifier = Modifier
.offset(
offset = {
IntOffset(
x = horizontalOffset.roundToInt(),
y = (verticalOffset + squareSize + textPadding).roundToInt(),
)
},
)
.width(squareSizeInDp),
text = stringResourceSafe(id = R.string.wallet_connect_paste_from_clipboard),
colors = TangemButtonsDefaults.defaultTextButtonColors.copy(contentColor = whiteColor),
onClick = pasteAction.action,
textStyle = TangemTheme.typography.subtitle1,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_copy_24),
showProgress = false,
enabled = true,
)
}
}

View file

@ -5,10 +5,17 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
@Immutable
data class QrScanningState(
internal data class QrScanningState(
val message: TextReference?,
val onQrScanned: (String) -> Unit,
val onBackClick: () -> Unit,
val onGalleryClick: () -> Unit,
val pasteAction: PasteAction = PasteAction.None,
val bottomSheetConfig: TangemBottomSheetConfig? = null,
)
)
@Immutable
internal sealed interface PasteAction {
data object None : PasteAction
class Perform(val action: () -> Unit) : PasteAction
}

View file

@ -1,14 +1,20 @@
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.feature.qrscanning.impl.R
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.model.QrScanningClickIntents
import com.tangem.feature.qrscanning.presentation.PasteAction
import com.tangem.feature.qrscanning.presentation.QrScanningState
private const val WC_SCHEME = "wc"
internal class InitializeQrScanningStateTransformer(
private val clickIntents: QrScanningClickIntents,
private val clipboardManager: ClipboardManager,
private val source: SourceType,
private val network: String?,
) : QrScanningTransformer {
@ -16,7 +22,7 @@ internal class InitializeQrScanningStateTransformer(
override fun transform(prevState: QrScanningState): QrScanningState {
val message = when (source) {
SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) }
else -> null
SourceType.WALLET_CONNECT -> stringReference("Open Web3 app and chose WalletConnect option")
}
return QrScanningState(
@ -24,6 +30,20 @@ internal class InitializeQrScanningStateTransformer(
onBackClick = clickIntents::onBackClick,
onQrScanned = clickIntents::onQrScanned,
onGalleryClick = clickIntents::onGalleryClicked,
pasteAction = constructPasteAction(),
)
}
private fun constructPasteAction(): PasteAction {
val uri = clipboardManager.getText()
return if (source == SourceType.WALLET_CONNECT && uri != null && isWalletConnectUri(uri)) {
PasteAction.Perform { clickIntents.onQrScanned(uri) }
} else {
PasteAction.None
}
}
private fun isWalletConnectUri(uri: String): Boolean {
return uri.lowercase().startsWith(WC_SCHEME)
}
}

View file

@ -117,10 +117,7 @@ internal class SendDestinationModel @Inject constructor(
override fun onQrCodeScanClick() {
analyticsEventHandler.send(SendDestinationAnalyticEvents.QrCodeButtonClicked(analyticsCategoryName))
router.push(
AppRoute.QrScanning(
source = AppRoute.QrScanning.Source.SEND,
networkName = cryptoCurrency.network.name,
),
AppRoute.QrScanning(source = AppRoute.QrScanning.Source.Send(cryptoCurrency.network.name)),
)
}

View file

@ -33,10 +33,7 @@ internal class DefaultSendRouter @Inject constructor(
override fun openQrCodeScanner(network: String) {
router.push(
AppRoute.QrScanning(
source = AppRoute.QrScanning.Source.SEND,
networkName = network,
),
AppRoute.QrScanning(source = AppRoute.QrScanning.Source.Send(network)),
)
}
}

View file

@ -18,11 +18,16 @@ dependencies {
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.common.routing)
/** Domain models */
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
/** Domain */
implementation(projects.domain.qrScanning)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
@ -39,6 +44,7 @@ dependencies {
implementation(deps.compose.ui.tooling)
/** Other */
implementation(deps.arrow.core)
implementation(deps.decompose.ext.compose)
implementation(deps.kotlin.immutable.collections)
implementation(deps.timber)

View file

@ -1,5 +1,7 @@
package com.tangem.features.walletconnect.connections.model
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
@ -7,19 +9,21 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.features.walletconnect.connections.entity.WcConnectionsState
import com.tangem.features.walletconnect.connections.ui.preview.WcConnectionsPreviewData
import com.tangem.features.walletconnect.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.*
import timber.log.Timber
import javax.inject.Inject
@ModelScoped
internal class WcConnectionsModel @Inject constructor(
private val router: Router,
private val uiMessageSender: UiMessageSender,
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
@ -31,8 +35,22 @@ internal class WcConnectionsModel @Inject constructor(
),
).asStateFlow()
init {
listenQrUpdates()
}
private fun listenQrUpdates() {
listenToQrScanningUseCase(SourceType.WALLET_CONNECT)
.getOrElse { emptyFlow() }
.onEach {
// TODO: [REDACTED_JIRA]
Timber.d(it)
}
.launchIn(modelScope)
}
private fun openQrScanScreen() {
// TODO("[REDACTED_JIRA]")
router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.WalletConnect))
}
private fun disconnectAll() {