Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-26 18:35:44 +02:00
parent 96aebcdba2
commit 477aa3c2c4
42 changed files with 447 additions and 166 deletions

View file

@ -1,5 +1,6 @@
package com.tangem.tap.features.details.redux.walletconnect
import androidx.core.os.bundleOf
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.derivation.DerivationStyle
@ -16,6 +17,8 @@ import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.walletconnect.WalletConnectActions
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.feature.qrscanning.SourceType
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
@ -98,7 +101,14 @@ class WalletConnectMiddleware {
if (uri != null && isWalletConnectUri(uri)) {
store.dispatchOnMain(WalletConnectAction.ShowClipboardOrScanQrDialog(uri))
} else {
store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.QrScan))
store.dispatchOnMain(
NavigationAction.NavigateTo(
screen = AppScreen.QrScanning,
bundle = bundleOf(
QrScanningRouter.SOURCE_KEY to SourceType.WALLET_CONNECT,
),
),
)
}
}
is WalletConnectAction.SelectNetwork -> {

View file

@ -1,108 +0,0 @@
package com.tangem.tap.features.details.ui.walletconnect
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.*
import androidx.activity.OnBackPressedCallback
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.fragment.app.Fragment
import by.kirich1409.viewbindingdelegate.viewBinding
import com.google.common.util.concurrent.ListenableFuture
import com.otaliastudios.cameraview.CameraView
import com.tangem.core.navigation.NavigationAction
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction
import com.tangem.tap.features.details.ui.walletconnect.dialogs.PreviewBinder
import com.tangem.tap.store
import com.tangem.wallet.R
import com.tangem.wallet.databinding.LayoutQrScanningBinding
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
internal class QrScanFragment : Fragment(R.layout.layout_qr_scanning) {
private val binding: LayoutQrScanningBinding by viewBinding(LayoutQrScanningBinding::bind)
private val binder = PreviewBinder()
private var cameraProviderFuture: ListenableFuture<ProcessCameraProvider>? = null
private var cameraExecutor: ExecutorService? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setFitSystemWindows(fit = true)
activity?.onBackPressedDispatcher?.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
store.dispatch(NavigationAction.PopBackTo())
}
},
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (!permissionIsGranted()) requestPermission()
cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
cameraExecutor = Executors.newSingleThreadExecutor()
cameraProviderFuture?.addListener(
{
val cameraProvider = cameraProviderFuture?.get()
binder.bindPreview(
context = requireContext(),
binding = binding,
lifecycleOwner = this,
cameraProvider = requireNotNull(cameraProvider),
cameraExecutor = requireNotNull(cameraExecutor),
onScanned = { result ->
store.dispatch(NavigationAction.PopBackTo())
setFitSystemWindows(fit = false)
if (result.isNotBlank()) {
store.dispatch(WalletConnectAction.OpenSession(result))
}
},
)
},
ContextCompat.getMainExecutor(requireContext()),
)
binding.overlay.post {
binding.overlay.setViewFinder()
}
}
override fun onDestroy() {
super.onDestroy()
setFitSystemWindows(fit = false)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (requestCode != CameraView.PERMISSION_REQUEST_CODE) return
if (grantResults.isEmpty() || grantResults[0] != PackageManager.PERMISSION_GRANTED) {
store.dispatch(WalletConnectAction.NotifyCameraPermissionIsRequired)
store.dispatch(NavigationAction.PopBackTo())
}
}
private fun permissionIsGranted(): Boolean {
val cameraPermission = ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA)
return cameraPermission == PackageManager.PERMISSION_GRANTED
}
private fun requestPermission() {
requestPermissions(arrayOf(Manifest.permission.CAMERA), CameraView.PERMISSION_REQUEST_CODE)
}
private fun setFitSystemWindows(fit: Boolean) {
activity?.window?.let {
WindowCompat.setDecorFitsSystemWindows(it, fit)
}
}
}

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.fragment.app.viewModels
import com.tangem.core.analytics.Analytics
import com.tangem.core.navigation.NavigationAction
import com.tangem.core.ui.screen.ComposeFragment
@ -23,22 +24,25 @@ internal class WalletConnectFragment : ComposeFragment(), StoreSubscriber<Wallet
@Inject
override lateinit var appThemeModeHolder: AppThemeModeHolder
private val viewModel = WalletConnectViewModel(store)
private var screenState: MutableState<WalletConnectScreenState> =
mutableStateOf(viewModel.updateState(store.state.walletConnectState))
private val viewModel: WalletConnectViewModel by viewModels()
private var screenState: MutableState<WalletConnectScreenState>? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Analytics.send(WalletConnect.ScreenOpened())
lifecycle.addObserver(viewModel)
screenState = mutableStateOf(viewModel.updateState(store.state.walletConnectState))
}
@Composable
override fun ScreenContent(modifier: Modifier) {
val state = screenState?.value ?: return
WalletConnectScreen(
modifier = modifier,
state = screenState.value,
state = state,
onBackClick = {
if (screenState.value.isLoading) {
if (state.isLoading) {
store.dispatch(
WalletConnectAction.FailureEstablishingSession(
store.state.walletConnectState.newSessionData?.session?.session,
@ -66,6 +70,6 @@ internal class WalletConnectFragment : ComposeFragment(), StoreSubscriber<Wallet
override fun newState(state: WalletConnectState) {
if (activity == null || view == null) return
screenState.value = viewModel.updateState(state)
screenState?.value = viewModel.updateState(state)
}
}

View file

@ -1,14 +1,34 @@
package com.tangem.tap.features.details.ui.walletconnect
import com.tangem.tap.common.redux.AppState
import androidx.lifecycle.*
import arrow.core.getOrElse
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.usecase.ListenToQrScanningUseCase
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectSession
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectState
import com.tangem.tap.store
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.toImmutableList
import org.rekotlin.Store
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
internal class WalletConnectViewModel @Inject constructor(
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
) : ViewModel(), DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
viewModelScope.launch {
listenToQrScanningUseCase(SourceType.WALLET_CONNECT)
.getOrElse { emptyFlow() }
.flowWithLifecycle(owner.lifecycle, minActiveState = Lifecycle.State.CREATED)
.collect { store.dispatch(WalletConnectAction.OpenSession(it)) }
}
}
internal class WalletConnectViewModel(private val store: Store<AppState>) {
fun updateState(state: WalletConnectState): WalletConnectScreenState {
Timber.d("WC2 Sessions: ${state.wc2Sessions}")
val sessions = state.sessions.map { wcSession -> WcSessionForScreen.fromSession(wcSession) } + state.wc2Sessions

View file

@ -2,9 +2,12 @@ package com.tangem.tap.features.details.ui.walletconnect.dialogs
import android.content.Context
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.feature.qrscanning.SourceType
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction
import com.tangem.tap.store
@ -19,7 +22,14 @@ object ClipboardOrScanQrDialog {
store.dispatch(WalletConnectAction.OpenSession(wcUri))
}
setNegativeButton(context.getText(R.string.wallet_connect_scan_new_code)) { _, _ ->
store.dispatch(NavigationAction.NavigateTo(AppScreen.QrScan))
store.dispatch(
NavigationAction.NavigateTo(
screen = AppScreen.QrScanning,
bundle = bundleOf(
QrScanningRouter.SOURCE_KEY to SourceType.WALLET_CONNECT,
),
),
)
}
setOnDismissListener {
store.dispatch(GlobalAction.HideDialog)

View file

@ -1,69 +0,0 @@
package com.tangem.tap.features.details.ui.walletconnect.dialogs
import android.content.Context
import android.util.Size
import android.view.OrientationEventListener
import android.view.Surface
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.lifecycle.LifecycleOwner
import com.tangem.tap.common.qrCodeScan.MLKitBarcodeAnalyzer
import com.tangem.wallet.databinding.LayoutQrScanningBinding
import java.util.concurrent.ExecutorService
internal class PreviewBinder {
@Suppress("LongParameterList")
fun bindPreview(
context: Context,
binding: LayoutQrScanningBinding,
lifecycleOwner: LifecycleOwner,
cameraProvider: ProcessCameraProvider,
cameraExecutor: ExecutorService,
onScanned: (String) -> Unit,
) {
cameraProvider.unbindAll()
val preview: Preview = Preview.Builder()
.build()
val imageAnalysis = ImageAnalysis.Builder()
.setTargetResolution(Size(binding.cameraPreview.width, binding.cameraPreview.height))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
val orientationEventListener = object : OrientationEventListener(context) {
@Suppress("MagicNumber")
override fun onOrientationChanged(orientation: Int) {
val rotation: Int = when (orientation) {
in 45..134 -> Surface.ROTATION_270
in 135..224 -> Surface.ROTATION_180
in 225..314 -> Surface.ROTATION_90
else -> Surface.ROTATION_0
}
imageAnalysis.targetRotation = rotation
}
}
orientationEventListener.enable()
val analyzer: ImageAnalysis.Analyzer = MLKitBarcodeAnalyzer {
imageAnalysis.clearAnalyzer()
onScanned.invoke(it)
}
cameraExecutor.let {
imageAnalysis.setAnalyzer(it, analyzer)
}
preview.setSurfaceProvider(binding.cameraPreview.surfaceProvider)
val cameraSelector: CameraSelector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, imageAnalysis, preview)
}
}

View file

@ -3,30 +3,38 @@
package com.tangem.tap.features.send.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.method.DigitsKeyListener
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import androidx.core.os.bundleOf
import androidx.core.view.postDelayed
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import arrow.core.getOrElse
import by.kirich1409.viewbindingdelegate.viewBinding
import com.google.android.material.textfield.TextInputEditText
import com.tangem.Message
import com.tangem.core.analytics.Analytics
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.usecase.ListenToQrScanningUseCase
import com.tangem.sdk.extensions.hideSoftKeyboard
import com.tangem.tap.common.KeyboardObserver
import com.tangem.tap.common.analytics.events.Token
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.getFromClipboard
import com.tangem.tap.common.extensions.setOnImeActionListener
import com.tangem.tap.common.qrCodeScan.ScanQrCodeActivity
import com.tangem.tap.common.recyclerView.SpaceItemDecoration
import com.tangem.tap.common.snackBar.MaxAmountSnackbar
import com.tangem.tap.common.text.truncateMiddleWith
@ -49,8 +57,11 @@ import com.tangem.wallet.databinding.FragmentSendBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.text.DecimalFormatSymbols
import javax.inject.Inject
private const val EDIT_TEXT_INPUT_DEBOUNCE = 400L
@ -73,11 +84,15 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
val binding: FragmentSendBinding by viewBinding(FragmentSendBinding::bind)
@Inject
lateinit var listenToQrScanningUseCase: ListenToQrScanningUseCase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(viewModel)
sendSubscriber.initViewModel(viewModel)
Analytics.send(Token.Send.ScreenOpened())
listenToQrCode()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -145,13 +160,34 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
}
imvQrCode.setOnClickListener {
Analytics.send(Token.Send.ButtonQRCode())
startActivityForResult(
Intent(requireContext(), ScanQrCodeActivity::class.java),
ScanQrCodeActivity.SCAN_QR_REQUEST_CODE,
store.dispatchOnMain(
NavigationAction.NavigateTo(
screen = AppScreen.QrScanning,
bundle = bundleOf(
QrScanningRouter.SOURCE_KEY to SourceType.SEND,
),
),
)
}
}
private fun listenToQrCode() {
lifecycleScope.launch {
listenToQrScanningUseCase(SourceType.SEND)
.getOrElse { emptyFlow() }
.flowWithLifecycle(this@SendFragment.lifecycle, minActiveState = Lifecycle.State.CREATED)
.collect {
delay(200)
// Delayed launch is needed in order for the UI to be drawn and to process the sent events.
// If do not use the delay, then etAmount error field is not displayed when
// inserting an incorrect amount by shareUri
onCodeScanned(it)
}
}
}
private fun setupTransactionExtrasLayout() = with(binding.lSendAddress) {
// TODO: [REDACTED_TASK_KEY]
etXlmMemo.inputtedTextAsFlow()
@ -200,27 +236,16 @@ class SendFragment : BaseStoreFragment(R.layout.fragment_send) {
.launchIn(mainScope)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode != ScanQrCodeActivity.SCAN_QR_REQUEST_CODE) return
val scannedCode = data?.getStringExtra(ScanQrCodeActivity.SCAN_RESULT) ?: ""
private fun onCodeScanned(scannedCode: String) {
if (scannedCode.isEmpty()) return
// Delayed launch is needed in order for the UI to be drawn and to process the sent events.
// If do not use the delay, then etAmount error field is not displayed when
// inserting an incorrect amount by shareUri
binding.lSendAddress.imvQrCode.postDelayed(
{
store.dispatch(
PasteAddress(
data = scannedCode,
sourceType = Token.Send.AddressEntered.SourceType.QRCode,
),
)
store.dispatch(TruncateOrRestore(!binding.lSendAddress.etAddress.isFocused))
},
200,
store.dispatch(
PasteAddress(
data = scannedCode,
sourceType = Token.Send.AddressEntered.SourceType.QRCode,
),
)
store.dispatch(TruncateOrRestore(!binding.lSendAddress.etAddress.isFocused))
}
private fun setupAmountLayout() {