Updated on 2026-08-14

This commit is contained in:
Tangem 2025-02-20 13:32:25 +03:00
parent 886dc6ee32
commit 4697ca6584
21 changed files with 323 additions and 412 deletions

View file

@ -9,6 +9,12 @@ android {
}
dependencies {
/** Domain models */
implementation(projects.domain.qrScanning.models)
/** Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
/** AndroidX */
implementation(deps.androidx.fragment.ktx)

View file

@ -0,0 +1,15 @@
package com.tangem.feature.qrscanning
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.qrscanning.models.SourceType
interface QrScanningComponent : ComposableContentComponent {
data class Params(
val source: SourceType,
val networkName: String? = null,
)
interface Factory : ComponentFactory<Params, QrScanningComponent>
}

View file

@ -1,8 +0,0 @@
package com.tangem.feature.qrscanning
import androidx.fragment.app.Fragment
interface QrScanningRouter {
fun getEntryFragment(): Fragment
}

View file

@ -14,6 +14,7 @@ dependencies {
/** Core */
implementation(projects.core.ui)
implementation(projects.core.decompose)
implementation(projects.core.utils)
implementation(projects.core.navigation)
implementation(projects.common.routing)

View file

@ -0,0 +1,142 @@
package com.tangem.feature.qrscanning
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.essenty.lifecycle.doOnDestroy
import com.google.mlkit.vision.common.InputImage
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.SystemBarsIconsDisposable
import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer
import com.tangem.feature.qrscanning.model.QrScanningModel
import com.tangem.feature.qrscanning.presentation.QrScanningContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.delay
import timber.log.Timber
import java.io.IOException
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class DefaultQrScanningComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: QrScanningComponent.Params,
) : QrScanningComponent, AppComponentContext by appComponentContext {
private val model: QrScanningModel = getOrCreateModel(params)
private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
// Camera requires its own analyzer instance due to flow of frames needed to be analyzed.
// Each new frame can cancel previous analysis e.i. image from the gallery can be skipped.
private val cameraAnalyzer: MLKitBarcodeAnalyzer by lazy(LazyThreadSafetyMode.NONE) {
MLKitBarcodeAnalyzer(model::onQrScanned)
}
private val analyzer: MLKitBarcodeAnalyzer by lazy(LazyThreadSafetyMode.NONE) {
MLKitBarcodeAnalyzer(model::onQrScanned)
}
init {
lifecycle.doOnDestroy { cameraExecutor.shutdown() }
}
@Composable
override fun Content(modifier: Modifier) {
val context = LocalContext.current
val cameraPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted.not()) {
model.onCameraDeniedState()
}
}
val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
val selectedImage = it ?: Uri.EMPTY
if (selectedImage != Uri.EMPTY) {
val mimeType = context.contentResolver.getType(selectedImage)
if (mimeType.isImageMimeType()) {
try {
val image = InputImage.fromFilePath(context, selectedImage)
analyzer.analyze(image)
} catch (e: IOException) {
Timber.e(e, "Unable to get image $selectedImage from gallery")
}
}
}
}
LaunchedEffect(Unit) {
model.launchGallery.collect {
galleryLauncher.launch(GALLERY_IMAGE_FILTER)
delay(timeMillis = 2000)
}
}
LifecycleEventEffect(
event = Lifecycle.Event.ON_CREATE,
) {
if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_DENIED
) {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
LifecycleEventEffect(
event = Lifecycle.Event.ON_RESUME,
) {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED
) {
model.onDismissBottomSheetState()
}
}
ScreenContent(modifier)
}
@Suppress("UnusedPrivateMember")
@Composable
private fun ScreenContent(modifier: Modifier = Modifier) {
SystemBarsIconsDisposable(darkIcons = false)
QrScanningContent(
executor = { cameraExecutor },
analyzer = { cameraAnalyzer },
uiState = model.uiState.collectAsStateWithLifecycle().value,
)
}
private fun String?.isImageMimeType() = this?.startsWith(prefix = "$IMAGE_MIME_TYPE/") == true
@AssistedFactory
interface Factory : QrScanningComponent.Factory {
override fun create(
context: AppComponentContext,
params: QrScanningComponent.Params,
): DefaultQrScanningComponent
}
companion object {
private const val IMAGE_MIME_TYPE = "image"
private const val GALLERY_IMAGE_FILTER = "$IMAGE_MIME_TYPE/*"
}
}

View file

@ -1,158 +0,0 @@
package com.tangem.feature.qrscanning
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.mlkit.vision.common.InputImage
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.SystemBarsIconsDisposable
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import com.tangem.feature.qrscanning.presentation.QrScanningContent
import com.tangem.feature.qrscanning.viewmodel.QrScanningViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.IOException
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import javax.inject.Inject
import kotlin.properties.Delegates
@AndroidEntryPoint
internal class QrScanningFragment : ComposeFragment() {
@Inject
override lateinit var uiDependencies: UiDependencies
@Inject
lateinit var router: QrScanningRouter
private val innerRouter: QrScanningInnerRouter
get() = requireNotNull(router as? QrScanningInnerRouter) {
"innerRouter should be instance of QrScanningInnerRouter"
}
private val viewModel by viewModels<QrScanningViewModel>()
private var cameraExecutor: ExecutorService by Delegates.notNull()
// Camera requires its own analyzer instance due to flow of frames needed to be analyzed.
// Each new frame can cancel previous analysis e.i. image from the gallery can be skipped.
private val cameraAnalyzer: MLKitBarcodeAnalyzer by lazy(LazyThreadSafetyMode.NONE) {
MLKitBarcodeAnalyzer(viewModel::onQrScanned)
}
private val analyzer: MLKitBarcodeAnalyzer by lazy(LazyThreadSafetyMode.NONE) {
MLKitBarcodeAnalyzer(viewModel::onQrScanned)
}
private val cameraPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (!it) viewModel.onCameraDeniedState()
}
private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) {
val selectedImage = it ?: Uri.EMPTY
if (selectedImage != Uri.EMPTY) {
val mimeType = requireContext().contentResolver.getType(selectedImage)
if (mimeType.isImageMimeType()) {
try {
val image = InputImage.fromFilePath(requireContext(), selectedImage)
analyzer.analyze(image)
} catch (e: IOException) {
Timber.e(e, "Unable to get image $selectedImage from gallery")
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.setRouter(innerRouter)
cameraExecutor = Executors.newSingleThreadExecutor()
requestCameraPermission()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.launchGalleryEvent
.collect {
galleryLauncher.launch(GALLERY_IMAGE_FILTER)
delay(timeMillis = 2000)
}
}
}
}
override fun onResume() {
super.onResume()
checkPermissionGranted()
}
override fun onDestroy() {
super.onDestroy()
cameraPermissionLauncher.unregister()
cameraExecutor.shutdown()
}
@Composable
override fun ScreenContent(modifier: Modifier) {
SystemBarsIconsDisposable(darkIcons = false)
QrScanningContent(
executor = { cameraExecutor },
analyzer = { cameraAnalyzer },
uiState = viewModel.uiState.collectAsStateWithLifecycle().value,
)
}
/**
* Method for requesting permission if there isn't one.
*/
private fun requestCameraPermission() {
if (
ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_DENIED
) {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
/**
* Method for checking if permission was granted after user opened Settings screen.
* If permission was granted dismiss bottom sheet.
*/
private fun checkPermissionGranted() {
if (ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED
) {
viewModel.onDismissBottomSheetState()
}
}
private fun String?.isImageMimeType() = this?.startsWith(prefix = "$IMAGE_MIME_TYPE/") == true
companion object {
private const val IMAGE_MIME_TYPE = "image"
private const val GALLERY_IMAGE_FILTER = "$IMAGE_MIME_TYPE/*"
fun create() = QrScanningFragment()
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.feature.qrscanning.di
import com.tangem.core.decompose.model.Model
import com.tangem.feature.qrscanning.DefaultQrScanningComponent
import com.tangem.feature.qrscanning.QrScanningComponent
import com.tangem.feature.qrscanning.model.QrScanningModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(SingletonComponent::class)
internal interface QrScanningFeatureModule {
@Binds
fun bindComponentFactory(impl: DefaultQrScanningComponent.Factory): QrScanningComponent.Factory
@Binds
@IntoMap
@ClassKey(QrScanningModel::class)
fun bindModel(model: QrScanningModel): Model
}

View file

@ -1,21 +0,0 @@
package com.tangem.feature.qrscanning.di
import com.tangem.common.routing.AppRouter
import com.tangem.feature.qrscanning.QrScanningRouter
import com.tangem.feature.qrscanning.navigation.DefaultQrScanningRouter
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(appRouter: AppRouter): QrScanningRouter {
return DefaultQrScanningRouter(appRouter)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.feature.qrscanning.model
import kotlinx.coroutines.flow.SharedFlow
internal interface QrScanningClickIntents {
val launchGallery: SharedFlow<Unit>
fun onBackClick()
fun onQrScanned(qrCode: String)
fun onGalleryClicked()
fun onSettingsClick()
}

View file

@ -0,0 +1,91 @@
package com.tangem.feature.qrscanning.model
import androidx.compose.runtime.Stable
import com.tangem.common.routing.AppRouter
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.feature.qrscanning.QrScanningComponent
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.presentation.QrScanningStateController
import com.tangem.feature.qrscanning.presentation.transformers.DismissBottomSheetTransformer
import com.tangem.feature.qrscanning.presentation.transformers.InitializeQrScanningStateTransformer
import com.tangem.feature.qrscanning.presentation.transformers.ShowCameraDeniedBottomSheetTransformer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@Stable
@ComponentScoped
internal class QrScanningModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val stateHolder: QrScanningStateController,
private val cardSdkProvider: CardSdkProvider,
private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase,
private val settingsManager: SettingsManager,
private val appRouter: AppRouter,
) : Model(), QrScanningClickIntents {
private val params = paramsContainer.require<QrScanningComponent.Params>()
val uiState: StateFlow<QrScanningState> = stateHolder.uiState
private var isScanned = false
override val launchGallery = MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_LATEST,
)
init {
// 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))
}
fun onCameraDeniedState() {
stateHolder.update(ShowCameraDeniedBottomSheetTransformer(this))
}
fun onDismissBottomSheetState() {
stateHolder.update(DismissBottomSheetTransformer())
}
override fun onBackClick() = appRouter.pop()
override fun onQrScanned(qrCode: String) {
if (qrCode.isNotBlank()) {
modelScope.launch(dispatchers.mainImmediate) {
emitQrScannedEventUseCase.invoke(params.source, qrCode)
}
if (!isScanned) {
appRouter.pop()
isScanned = true
}
}
}
override fun onGalleryClicked() {
launchGallery.tryEmit(Unit)
if (stateHolder.value.bottomSheetConfig != null) {
stateHolder.update(DismissBottomSheetTransformer())
}
}
override fun onSettingsClick() {
settingsManager.openAppSettings()
}
override fun onDestroy() {
super.onDestroy()
// don't forget enable reader mode after scan complete
cardSdkProvider.sdk.forceEnableReaderMode()
}
}

View file

@ -1,16 +0,0 @@
package com.tangem.feature.qrscanning.navigation
import androidx.fragment.app.Fragment
import com.tangem.common.routing.AppRouter
import com.tangem.feature.qrscanning.QrScanningFragment
class DefaultQrScanningRouter(
private val router: AppRouter,
) : QrScanningInnerRouter {
override fun getEntryFragment(): Fragment = QrScanningFragment.create()
override fun popBackStack() {
router.pop()
}
}

View file

@ -1,8 +0,0 @@
package com.tangem.feature.qrscanning.navigation
import com.tangem.feature.qrscanning.QrScanningRouter
interface QrScanningInnerRouter : QrScanningRouter {
fun popBackStack()
}

View file

@ -5,7 +5,7 @@ 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.viewmodel.QrScanningClickIntents
import com.tangem.feature.qrscanning.model.QrScanningClickIntents
internal class InitializeQrScanningStateTransformer(
private val clickIntents: QrScanningClickIntents,

View file

@ -3,7 +3,7 @@ package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.feature.qrscanning.presentation.CameraDeniedBottomSheetConfig
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents
import com.tangem.feature.qrscanning.model.QrScanningClickIntents
internal class ShowCameraDeniedBottomSheetTransformer(
private val clickIntents: QrScanningClickIntents,

View file

@ -1,23 +0,0 @@
package com.tangem.feature.qrscanning.viewmodel
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import kotlinx.coroutines.CoroutineScope
import kotlin.properties.Delegates
internal open class BaseQrScanningClickIntents {
protected val router: QrScanningInnerRouter get() = _router
protected val viewModelScope: CoroutineScope get() = _viewModelScope
protected val source: SourceType get() = _source
private var _router: QrScanningInnerRouter by Delegates.notNull()
private var _viewModelScope: CoroutineScope by Delegates.notNull()
private var _source: SourceType by Delegates.notNull()
open fun initialize(router: QrScanningInnerRouter, source: SourceType, coroutineScope: CoroutineScope) {
_router = router
_viewModelScope = coroutineScope
_source = source
}
}

View file

@ -1,67 +0,0 @@
package com.tangem.feature.qrscanning.viewmodel
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.feature.qrscanning.presentation.QrScanningStateController
import com.tangem.feature.qrscanning.presentation.transformers.DismissBottomSheetTransformer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
internal interface QrScanningClickIntents {
val launchGallery: SharedFlow<Unit>
fun onBackClick()
fun onQrScanned(qrCode: String)
fun onGalleryClicked()
fun onSettingsClick()
}
@ViewModelScoped
internal class QrScanningClickIntentsImplementor @Inject constructor(
private val stateHolder: QrScanningStateController,
private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase,
private val settingsManager: SettingsManager,
private val dispatcher: CoroutineDispatcherProvider,
) : BaseQrScanningClickIntents(), QrScanningClickIntents {
private var isScanned = false
override val launchGallery = MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_LATEST,
)
override fun onBackClick() = router.popBackStack()
override fun onQrScanned(qrCode: String) {
if (qrCode.isNotBlank()) {
viewModelScope.launch(dispatcher.mainImmediate) {
emitQrScannedEventUseCase.invoke(source, qrCode)
}
if (!isScanned) {
router.popBackStack()
isScanned = true
}
}
}
override fun onGalleryClicked() {
launchGallery.tryEmit(Unit)
if (stateHolder.value.bottomSheetConfig != null) {
stateHolder.update(DismissBottomSheetTransformer())
}
}
override fun onSettingsClick() {
settingsManager.openAppSettings()
}
}

View file

@ -1,66 +0,0 @@
package com.tangem.feature.qrscanning.viewmodel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.common.routing.AppRoute
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.presentation.QrScanningStateController
import com.tangem.feature.qrscanning.presentation.transformers.DismissBottomSheetTransformer
import com.tangem.feature.qrscanning.presentation.transformers.InitializeQrScanningStateTransformer
import com.tangem.feature.qrscanning.presentation.transformers.ShowCameraDeniedBottomSheetTransformer
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@HiltViewModel
internal class QrScanningViewModel @Inject constructor(
private val stateHolder: QrScanningStateController,
private val clickIntents: QrScanningClickIntentsImplementor,
private val cardSdkProvider: CardSdkProvider,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val source: SourceType = savedStateHandle.get<Int>(AppRoute.QrScanning.SOURCE_KEY)
?.let { SourceType.entries[it] }
?: error("Source is mandatory")
private val network: String? = savedStateHandle[AppRoute.QrScanning.NETWORK_KEY]
val uiState: StateFlow<QrScanningState> = stateHolder.uiState
val launchGalleryEvent: SharedFlow<Unit> = clickIntents.launchGallery
init {
// samsung for some reason disables reader mode, and then it works unstable
// to prevent this disable ir manually before scan QR
cardSdkProvider.sdk.forceDisableReaderMode()
}
fun setRouter(router: QrScanningInnerRouter) {
clickIntents.initialize(
router = router,
source = source,
coroutineScope = viewModelScope,
)
stateHolder.update(InitializeQrScanningStateTransformer(clickIntents, source, network))
}
fun onQrScanned(qrCode: String) = clickIntents.onQrScanned(qrCode)
fun onCameraDeniedState() {
stateHolder.update(ShowCameraDeniedBottomSheetTransformer(clickIntents))
}
fun onDismissBottomSheetState() {
stateHolder.update(DismissBottomSheetTransformer())
}
override fun onCleared() {
super.onCleared()
// don't forget enable reader mode after scan complete
cardSdkProvider.sdk.forceEnableReaderMode()
}
}