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

@ -96,6 +96,8 @@ dependencies {
implementation(projects.features.manageTokens.api)
implementation(projects.features.manageTokens.impl)
implementation(projects.features.send.impl)
implementation(projects.features.qrScanning.api)
implementation(projects.features.qrScanning.impl)
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)
@ -143,6 +145,7 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Other libraries */
@ -155,7 +158,6 @@ dependencies {
implementation(deps.timber)
implementation(deps.reKotlin)
implementation(deps.zxing.qrCore)
implementation(deps.otaliastudiosCameraView)
implementation(deps.coil)
implementation(deps.appsflyer)
implementation(deps.amplitude)

View file

@ -137,10 +137,6 @@
</activity>
<activity
android:name="com.tangem.tap.common.qrCodeScan.ScanQrCodeActivity"
android:theme="@style/AppTheme" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View file

@ -23,6 +23,7 @@ import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import arrow.core.getOrElse
import by.kirich1409.viewbindingdelegate.viewBinding
import com.tangem.feature.qrscanning.QrScanningRouter
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import com.tangem.core.navigation.AppScreen
@ -134,6 +135,9 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
@Inject
lateinit var sendRouter: SendRouter
@Inject
lateinit var qrScanningRouter: QrScanningRouter
internal val viewModel: MainViewModel by viewModels()
private lateinit var appThemeModeFlow: SharedFlow<AppThemeMode?>
@ -206,6 +210,7 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac
manageTokensRouter = manageTokensRouter,
cardSdkConfigRepository = cardSdkConfigRepository,
sendRouter = sendRouter,
qrScanningRouter = qrScanningRouter,
),
)
}

View file

@ -14,7 +14,6 @@ import com.tangem.tap.features.details.ui.cardsettings.coderecovery.AccessCodeRe
import com.tangem.tap.features.details.ui.details.DetailsFragment
import com.tangem.tap.features.details.ui.resetcard.ResetCardFragment
import com.tangem.tap.features.details.ui.securitymode.SecurityModeFragment
import com.tangem.tap.features.details.ui.walletconnect.QrScanFragment
import com.tangem.tap.features.details.ui.walletconnect.WalletConnectFragment
import com.tangem.tap.features.disclaimer.ui.DisclaimerFragment
import com.tangem.tap.features.home.HomeFragment
@ -171,7 +170,11 @@ private fun fragmentFactory(screen: AppScreen): Fragment {
.getEntryFragment()
}
AppScreen.WalletConnectSessions -> WalletConnectFragment()
AppScreen.QrScan -> QrScanFragment()
AppScreen.QrScanning -> {
store.state.daggerGraphState
.get(getDependency = DaggerGraphState::qrScanningRouter)
.getEntryFragment()
}
AppScreen.ReferralProgram -> ReferralFragment()
AppScreen.Swap -> SwapFragment()
AppScreen.Welcome -> WelcomeFragment()

View file

@ -1,87 +0,0 @@
package com.tangem.tap.common.qrCodeScan
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import by.kirich1409.viewbindingdelegate.viewBinding
import com.google.common.util.concurrent.ListenableFuture
import com.otaliastudios.cameraview.CameraView.PERMISSION_REQUEST_CODE
import com.tangem.tap.features.details.ui.walletconnect.dialogs.PreviewBinder
import com.tangem.wallet.R
import com.tangem.wallet.databinding.LayoutQrScanningBinding
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
/**
[REDACTED_AUTHOR]
*/
class ScanQrCodeActivity : AppCompatActivity() {
private val binding: LayoutQrScanningBinding by viewBinding(LayoutQrScanningBinding::bind)
private var cameraProviderFuture: ListenableFuture<ProcessCameraProvider>? = null
private var cameraExecutor: ExecutorService? = null
private val binder = PreviewBinder()
override fun onCreate(state: Bundle?) {
super.onCreate(state)
if (!permissionIsGranted()) requestPermission()
setContentView(R.layout.layout_qr_scanning)
cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraExecutor = Executors.newSingleThreadExecutor()
cameraProviderFuture?.addListener(
{
val cameraProvider = cameraProviderFuture?.get()
binder.bindPreview(
context = this,
binding = binding,
lifecycleOwner = this,
cameraProvider = requireNotNull(cameraProvider),
cameraExecutor = requireNotNull(cameraExecutor),
onScanned = { result ->
setResult(SCAN_QR_REQUEST_CODE, Intent().apply { putExtra(SCAN_RESULT, result) })
finish()
},
)
},
ContextCompat.getMainExecutor(this),
)
binding.overlay.post {
binding.overlay.setViewFinder()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != PERMISSION_REQUEST_CODE) return
if (grantResults.isEmpty() || grantResults[0] != PackageManager.PERMISSION_GRANTED) {
finish()
}
}
private fun permissionIsGranted(): Boolean {
val cameraPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
return cameraPermission == PackageManager.PERMISSION_GRANTED
}
private fun requestPermission() {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), PERMISSION_REQUEST_CODE)
}
companion object {
const val SCAN_QR_REQUEST_CODE = 1001
const val SCAN_RESULT = "scanResult"
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.tap.di.domain
import com.tangem.feature.qrscanning.repo.QrScanningEventsRepository
import com.tangem.feature.qrscanning.usecase.ListenToQrScanningUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object QrScanningDomainModule {
@Provides
@Singleton
fun provideListenToQrScanUseCase(repository: QrScanningEventsRepository): ListenToQrScanningUseCase {
return ListenToQrScanningUseCase(repository)
}
}

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

@ -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

@ -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() {

View file

@ -1,5 +1,6 @@
package com.tangem.tap.proxy.redux
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.features.managetokens.navigation.ManageTokensRouter
@ -21,5 +22,6 @@ sealed interface DaggerGraphAction : Action {
val manageTokensRouter: ManageTokensRouter,
val cardSdkConfigRepository: CardSdkConfigRepository,
val sendRouter: SendRouter,
val qrScanningRouter: QrScanningRouter,
) : DaggerGraphAction
}

View file

@ -21,6 +21,7 @@ object DaggerGraphReducer {
manageTokensRouter = action.manageTokensRouter,
cardSdkConfigRepository = action.cardSdkConfigRepository,
sendRouter = action.sendRouter,
qrScanningRouter = action.qrScanningRouter,
)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.tap.proxy.redux
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
@ -50,6 +51,7 @@ data class DaggerGraphState(
val networksRepository: NetworksRepository? = null,
val sendFeatureToggles: SendFeatureToggles? = null,
val sendRouter: SendRouter? = null,
val qrScanningRouter: QrScanningRouter? = null,
// FIXME: It is used only for TokensList screen. Remove after refactoring of TokensList
val currenciesRepository: CurrenciesRepository? = null,

View file

@ -36,7 +36,5 @@
<dimen name="access_code_info_items_space">16dp</dimen>
<dimen name="access_code_btn_continue_margin_bottom">38dp</dimen>
<dimen name="qr_border_stroke_width">4dp</dimen>
<dimen name="qr_border_corner_radius">8dp</dimen>
</resources>

View file

@ -29,7 +29,7 @@ enum class AppScreen(val isDialogFragment: Boolean = false) {
ManageTokens,
AddCustomToken,
WalletConnectSessions,
QrScan,
QrScanning,
ReferralProgram,
Swap,
Welcome,

View file

@ -3,5 +3,6 @@ package com.tangem.features.managetokens.navigation
import androidx.fragment.app.Fragment
interface ManageTokensRouter {
fun getEntryFragment(): Fragment
}

1
features/qr-scanning/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,15 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.feature.qrscanning.api"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.fragment.ktx)
}

View file

@ -0,0 +1,12 @@
package com.tangem.feature.qrscanning
import androidx.fragment.app.Fragment
interface QrScanningRouter {
fun getEntryFragment(): Fragment
companion object {
const val SOURCE_KEY = "source"
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.feature.qrscanning
enum class SourceType {
WALLET_CONNECT,
SEND,
}

1
features/qr-scanning/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,47 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.feature.qrscanning.impl"
viewBinding {
enable = true
}
}
dependencies {
implementation(deps.androidx.fragment.ktx)
implementation(deps.viewBindingDelegate)
/** Camera */
implementation(deps.camera.camera2)
implementation(deps.camera.lifecycle)
implementation(deps.camera.view)
implementation(deps.listenableFuture)
implementation(deps.mlKit.barcodeScanning)
/** Excluded dependencies */
implementation("com.google.guava:guava:30.0-android") {
// excludes version 9999.0-empty-to-avoid-conflict-with-guava
exclude(group="com.google.guava", module = "listenablefuture")
}
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
implementation(projects.features.qrScanning.api)
/** Compose */
implementation(deps.compose.foundation)
implementation(deps.arrow.core)
}

View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,5 @@
package com.tangem.feature.qrscanning
const val SCAN_QR_REQUEST_CODE = 1001
const val SCAN_RESULT = "scanResult"
const val PERMISSION_REQUEST_CODE = 16

View file

@ -0,0 +1,8 @@
package com.tangem.feature.qrscanning
import androidx.fragment.app.Fragment
internal class DefaultQrScanningRouter : QrScanningRouter {
override fun getEntryFragment(): Fragment = QrScanningFragment.create()
}

View file

@ -1,46 +1,42 @@
package com.tangem.tap.features.details.ui.walletconnect
package com.tangem.feature.qrscanning
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 androidx.fragment.app.viewModels
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 com.tangem.feature.qrscanning.impl.R
import com.tangem.feature.qrscanning.impl.databinding.LayoutQrScanningBinding
import com.tangem.feature.qrscanning.inner.PreviewBinder
import dagger.hilt.android.AndroidEntryPoint
// 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 java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
internal class QrScanFragment : Fragment(R.layout.layout_qr_scanning) {
@AndroidEntryPoint
internal class QrScanningFragment : Fragment(R.layout.layout_qr_scanning) {
private val binding: LayoutQrScanningBinding by viewBinding(LayoutQrScanningBinding::bind)
private val binder = PreviewBinder()
private val viewModel by viewModels<QrScanningViewModel>()
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?) {
@ -61,10 +57,10 @@ internal class QrScanFragment : Fragment(R.layout.layout_qr_scanning) {
cameraProvider = requireNotNull(cameraProvider),
cameraExecutor = requireNotNull(cameraExecutor),
onScanned = { result ->
store.dispatch(NavigationAction.PopBackTo())
parentFragmentManager.popBackStack()
setFitSystemWindows(fit = false)
if (result.isNotBlank()) {
store.dispatch(WalletConnectAction.OpenSession(result))
viewModel.onQrScanned(result)
}
},
)
@ -83,11 +79,10 @@ internal class QrScanFragment : Fragment(R.layout.layout_qr_scanning) {
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (requestCode != CameraView.PERMISSION_REQUEST_CODE) return
if (requestCode != PERMISSION_REQUEST_CODE) return
if (grantResults.isEmpty() || grantResults[0] != PackageManager.PERMISSION_GRANTED) {
store.dispatch(WalletConnectAction.NotifyCameraPermissionIsRequired)
store.dispatch(NavigationAction.PopBackTo())
parentFragmentManager.popBackStack()
}
}
@ -97,7 +92,7 @@ internal class QrScanFragment : Fragment(R.layout.layout_qr_scanning) {
}
private fun requestPermission() {
requestPermissions(arrayOf(Manifest.permission.CAMERA), CameraView.PERMISSION_REQUEST_CODE)
requestPermissions(arrayOf(Manifest.permission.CAMERA), PERMISSION_REQUEST_CODE)
}
private fun setFitSystemWindows(fit: Boolean) {
@ -105,4 +100,9 @@ internal class QrScanFragment : Fragment(R.layout.layout_qr_scanning) {
WindowCompat.setDecorFitsSystemWindows(it, fit)
}
}
companion object {
fun create() = QrScanningFragment()
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.feature.qrscanning
import com.tangem.feature.qrscanning.repo.DefaultQrScanningEventsRepository
import com.tangem.feature.qrscanning.repo.QrScanningEventsRepository
import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object QrScanningModule {
@Provides
@Singleton
fun provideQrScannedEventsRepository(): QrScanningEventsRepository {
return DefaultQrScanningEventsRepository()
}
@Provides
@Singleton
fun provideEmitQrScannedEventUseCase(repository: QrScanningEventsRepository): EmitQrScannedEventUseCase {
return EmitQrScannedEventUseCase(repository)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.feature.qrscanning
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.scopes.ActivityScoped
@Module
@InstallIn(ActivityComponent::class)
internal object QrScanningRouterModule {
@Provides
@ActivityScoped
fun provideQrScanRouter(): QrScanningRouter {
return DefaultQrScanningRouter()
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.feature.qrscanning
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.feature.qrscanning.QrScanningRouter.Companion.SOURCE_KEY
import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class QrScanningViewModel @Inject constructor(
private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val source: SourceType = savedStateHandle[SOURCE_KEY] ?: error("Source is mandatory")
fun onQrScanned(qrCode: String) {
viewModelScope.launch {
emitQrScannedEventUseCase.invoke(source, qrCode)
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.tap.common.qrCodeScan
package com.tangem.feature.qrscanning.inner
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis

View file

@ -1,4 +1,4 @@
package com.tangem.tap.features.details.ui.walletconnect.dialogs
package com.tangem.feature.qrscanning.inner
import android.content.Context
import android.util.Size
@ -9,8 +9,7 @@ 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 com.tangem.feature.qrscanning.impl.databinding.LayoutQrScanningBinding
import java.util.concurrent.ExecutorService
internal class PreviewBinder {

View file

@ -1,11 +1,11 @@
package com.tangem.tap.common.qrCodeScan
package com.tangem.feature.qrscanning.inner
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import androidx.core.content.ContextCompat
import com.tangem.wallet.R
import com.tangem.feature.qrscanning.impl.R
class ViewFinderOverlay(context: Context, attrs: AttributeSet) : View(context, attrs) {

View file

@ -0,0 +1,19 @@
package com.tangem.feature.qrscanning.repo
import com.tangem.feature.qrscanning.SourceType
import kotlinx.coroutines.flow.*
internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
private data class QrScanningEvent(val type: SourceType, val qrCode: String)
private val scannedEvents = MutableSharedFlow<QrScanningEvent>()
override suspend fun emitResult(type: SourceType, qrCode: String) {
scannedEvents.emit(QrScanningEvent(type, qrCode))
}
override fun subscribeToScanningResults(type: SourceType) = scannedEvents
.filter { it.type == type }
.map { it.qrCode }
}

View file

@ -0,0 +1,11 @@
package com.tangem.feature.qrscanning.repo
import com.tangem.feature.qrscanning.SourceType
import kotlinx.coroutines.flow.Flow
interface QrScanningEventsRepository {
suspend fun emitResult(type: SourceType, qrCode: String)
fun subscribeToScanningResults(type: SourceType): Flow<String>
}

View file

@ -0,0 +1,20 @@
package com.tangem.feature.qrscanning.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.repo.QrScanningEventsRepository
internal class EmitQrScannedEventUseCase(
private val repository: QrScanningEventsRepository,
) {
suspend operator fun invoke(type: SourceType, qrCode: String): Either<Exception, Unit> {
return try {
repository.emitResult(type, qrCode)
Unit.right()
} catch (e: Exception) {
e.left()
}
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.feature.qrscanning.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.repo.QrScanningEventsRepository
import kotlinx.coroutines.flow.Flow
import java.lang.Exception
class ListenToQrScanningUseCase(
val repository: QrScanningEventsRepository,
) {
operator fun invoke(type: SourceType): Either<Exception, Flow<String>> {
return try {
repository.subscribeToScanningResults(type).right()
} catch (e: Exception) {
e.left()
}
}
}

View file

@ -11,7 +11,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.tangem.tap.common.qrCodeScan.ViewFinderOverlay
<com.tangem.feature.qrscanning.inner.ViewFinderOverlay
android:id="@+id/overlay"
android:layerType="software"
android:layout_width="match_parent"

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="qr_border_stroke_width">4dp</dimen>
<dimen name="qr_border_corner_radius">8dp</dimen>
</resources>

View file

@ -59,7 +59,6 @@ kotsonGsonExt = "2.5.0"
lottie = "3.4.0"
moshi = "1.12.0"
okhttp = "4.9.3"
otaliastudiosCameraView = "2.6.3"
rekotlin = "1.0.4"
retrofit = "2.8.1"
retrofitMoshiConverter = "2.6.0"
@ -215,7 +214,6 @@ moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
okHttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okHttp-prettyLogging = { module = "com.github.ihsanbal:LoggingInterceptor", version.ref = "okHttp-prettyLogging" }
otaliastudiosCameraView = { module = "com.otaliastudios:cameraview", version.ref = "otaliastudiosCameraView" }
shopify-buy = { module = "com.shopify.mobilebuysdk:buy3", version.ref = "shopifyBuySdk" }
spongecastle-core = { module = "com.madgag.spongycastle:core", version.ref = "spongycastleCryptoCore" }
reKotlin = { module = "org.rekotlin:rekotlin", version.ref = "rekotlin" }

View file

@ -27,7 +27,7 @@ dependencyResolutionManagement {
google()
mavenCentral()
mavenLocal()
jcenter() // unable to replace with mavenCentral() due to rekotlin and com.otaliastudios:cameraview
jcenter() // unable to replace with mavenCentral() due to rekotlin
maven("https://nexus.tangem-tech.com/repository/maven-releases/")
maven {
// setting any repository from tangem project allows maven search all packages in the project
@ -101,6 +101,9 @@ include(":features:send:impl")
include(":features:manage-tokens:api")
include(":features:manage-tokens:impl")
include(":features:qr-scanning:api")
include(":features:qr-scanning:impl")
// endregion Feature modules
// region Domain modules
@ -140,4 +143,4 @@ include(":data:settings")
include(":data:txhistory")
include(":data:wallets")
include(":data:analytics")
// endregion Data modules
// endregion Data modules