Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-26 16:28:00 +04:00
parent f9f2ac60d0
commit c76d1de3cf
15 changed files with 211 additions and 84 deletions

View file

@ -7,7 +7,7 @@ import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.domain.card.ScanFailsRequester
import com.tangem.core.analytics.models.AnalyticsParam
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import com.tangem.scenarios.checkFailedTransactionDialog
@ -179,7 +179,7 @@ class FeedbackTest : BaseTestCase() {
runOnUiThread {
val requester = store.state.daggerGraphState.scanFailsRequester!!
MainScope().launch {
requester.show(ScanFailsRequester.Source.MAIN)
requester.show(AnalyticsParam.ScreensSources.Main)
}
}
}

View file

@ -1,23 +1,11 @@
package com.tangem.tap.common.redux.global
import com.tangem.common.CompletionResult
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.scan.ScanResponse
import org.rekotlin.Action
sealed class GlobalAction : Action {
object ScanFailsCounter {
data class ChooseBehavior(
val result: CompletionResult<ScanResponse>,
val analyticsSource: AnalyticsParam.ScreensSources,
) : GlobalAction()
object Reset : GlobalAction()
object Increment : GlobalAction()
}
data class SaveScanResponse(val scanResponse: ScanResponse) : GlobalAction()
data class ChangeAppCurrency(val appCurrency: AppCurrency) : GlobalAction()

View file

@ -1,11 +1,6 @@
package com.tangem.tap.common.redux.global
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.ScanFailsRequester
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState
@ -30,43 +25,12 @@ private val globalMiddlewareHandler: Middleware<AppState> = { _, _ ->
}
}
@Suppress("LongMethod", "ComplexMethod")
private fun handleAction(action: Action) {
when (action) {
is GlobalAction.ScanFailsCounter.ChooseBehavior -> {
when (action.result) {
is CompletionResult.Success -> store.dispatch(GlobalAction.ScanFailsCounter.Reset)
is CompletionResult.Failure -> {
handleFailureChooseBehaviour(action.result, action.analyticsSource)
}
}
}
is GlobalAction.RestoreAppCurrency -> restoreAppCurrency()
}
}
private fun handleFailureChooseBehaviour(
result: CompletionResult.Failure<ScanResponse>,
analyticsSource: AnalyticsParam.ScreensSources,
) {
if (result.error is TangemSdkError.UserCancelled) {
store.dispatch(GlobalAction.ScanFailsCounter.Increment)
if (store.state.globalState.scanCardFailsCounter >= 2) {
val scanFailsSource = when (analyticsSource) {
is AnalyticsParam.ScreensSources.SignIn -> ScanFailsRequester.Source.SIGN_IN
is AnalyticsParam.ScreensSources.Settings -> ScanFailsRequester.Source.SETTINGS
is AnalyticsParam.ScreensSources.Intro -> ScanFailsRequester.Source.INTRO
else -> ScanFailsRequester.Source.MAIN
}
scope.launch {
store.inject(DaggerGraphState::scanFailsRequester).show(scanFailsSource)
}
}
} else {
store.dispatch(GlobalAction.ScanFailsCounter.Reset)
}
}
private fun restoreAppCurrency() {
scope.launch {
val currency = store.inject(DaggerGraphState::appCurrencyRepository)

View file

@ -10,12 +10,6 @@ fun globalReducer(action: Action, state: AppState): GlobalState {
val globalState = state.globalState
return when (action) {
is GlobalAction.ScanFailsCounter.Increment -> {
globalState.copy(scanCardFailsCounter = globalState.scanCardFailsCounter + 1)
}
is GlobalAction.ScanFailsCounter.Reset -> {
globalState.copy(scanCardFailsCounter = 0)
}
is GlobalAction.SaveScanResponse -> {
globalState.copy(scanResponse = action.scanResponse)
}

View file

@ -10,7 +10,6 @@ data class GlobalState(
val scanResponse: ScanResponse? = null,
val tapWalletManager: TapWalletManager = TapWalletManager(),
val appCurrency: AppCurrency = AppCurrency.Default,
val scanCardFailsCounter: Int = 0,
val isLastSignWithRing: Boolean = false,
) : StateType

View file

@ -0,0 +1,37 @@
package com.tangem.tap.domain.scanCard
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.card.ScanFailsCounter
import com.tangem.domain.card.ScanFailsRequester
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class DefaultScanFailsCounter @Inject constructor(
private val scanFailsRequester: ScanFailsRequester,
private val appScope: AppCoroutineScope,
) : ScanFailsCounter {
private var counter: Int = 0
override fun reset() {
counter = 0
}
override fun onScanFailure(isUserCancelled: Boolean, source: AnalyticsParam.ScreensSources) {
if (isUserCancelled) {
counter++
if (counter >= THRESHOLD) {
appScope.launch { scanFailsRequester.show(source) }
}
} else {
counter = 0
}
}
private companion object {
const val THRESHOLD = 2
}
}

View file

@ -19,6 +19,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.toWrappedList
import com.tangem.core.ui.message.dialog.Dialogs
import com.tangem.domain.card.ScanFailsCounter
import com.tangem.domain.card.common.util.twinsIsTwinned
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.domain.feedback.models.FeedbackEmailType
@ -26,9 +27,7 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.sdk.extensions.localizedDescriptionRes
import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.disclaimer.createDisclaimer
import com.tangem.tap.features.onboarding.OnboardingHelper
import com.tangem.tap.mainScope
@ -49,6 +48,7 @@ internal class LegacyScanProcessor @Inject constructor(
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
private val trackingContextProxy: TrackingContextProxy,
private val scanFailsCounter: ScanFailsCounter,
) {
suspend fun scan(
@ -89,10 +89,13 @@ internal class LegacyScanProcessor @Inject constructor(
)
val analyticsEvent = Basic.CardWasScanned(analyticsSource)
store.dispatchOnMain(GlobalAction.ScanFailsCounter.ChooseBehavior(result, analyticsSource))
result
.doOnFailure { error ->
scanFailsCounter.onScanFailure(
isUserCancelled = error is TangemSdkError.UserCancelled,
source = analyticsSource,
)
onScanFailure(
analyticsSource = analyticsSource,
error = error,
@ -106,6 +109,7 @@ internal class LegacyScanProcessor @Inject constructor(
)
}
.doOnSuccess { scanResponse ->
scanFailsCounter.reset()
tangemSdkManager.changeDisplayedCardIdNumbersCount(scanResponse)
sendAnalytics(analyticsEvent, scanResponse)

View file

@ -9,7 +9,6 @@ import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.domain.card.ScanCardException
import com.tangem.domain.card.ScanFailsRequester
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent
import com.tangem.tap.common.extensions.dispatchNavigationAction
@ -73,14 +72,8 @@ internal object UseCaseScanProcessor {
}
private fun showScanFailsDialog(source: AnalyticsParam.ScreensSources) {
val scanFailsSource = when (source) {
is AnalyticsParam.ScreensSources.SignIn -> ScanFailsRequester.Source.SIGN_IN
is AnalyticsParam.ScreensSources.Settings -> ScanFailsRequester.Source.SETTINGS
is AnalyticsParam.ScreensSources.Intro -> ScanFailsRequester.Source.INTRO
else -> ScanFailsRequester.Source.MAIN
}
scope.launch {
store.inject(DaggerGraphState::scanFailsRequester).show(scanFailsSource)
store.inject(DaggerGraphState::scanFailsRequester).show(source)
}
}

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
@ -21,7 +22,7 @@ internal class ScanFailsComponent @AssistedInject constructor(
private val model: ScanFailsModel = getOrCreateModel(params)
override suspend fun show(source: ScanFailsRequester.Source): ScanFailsRequester.Result {
override suspend fun show(source: AnalyticsParam.ScreensSources): ScanFailsRequester.Result {
model.show(source)
return model.waitResult()
}

View file

@ -30,14 +30,13 @@ internal class ScanFailsModel @Inject constructor(
val uiState: StateFlow<ScanFailsUM>
field = MutableStateFlow(ScanFailsUM(onDismiss = ::dismiss))
fun show(source: ScanFailsRequester.Source) {
val analyticsSource = source.toAnalyticsSource()
fun show(source: AnalyticsParam.ScreensSources) {
result.value = null
uiState.update {
ScanFailsUM(
isShown = true,
onHowToScan = { onHowToScan(analyticsSource) },
onRequestSupport = { onRequestSupport(analyticsSource) },
onHowToScan = { onHowToScan(source) },
onRequestSupport = { onRequestSupport(source) },
onDismiss = ::dismiss,
)
}
@ -70,11 +69,4 @@ internal class ScanFailsModel @Inject constructor(
sendFeedbackEmailUseCase(type = FeedbackEmailType.ScanningProblem)
}
}
private fun ScanFailsRequester.Source.toAnalyticsSource(): AnalyticsParam.ScreensSources = when (this) {
ScanFailsRequester.Source.MAIN -> AnalyticsParam.ScreensSources.Main
ScanFailsRequester.Source.SIGN_IN -> AnalyticsParam.ScreensSources.SignIn
ScanFailsRequester.Source.SETTINGS -> AnalyticsParam.ScreensSources.Settings
ScanFailsRequester.Source.INTRO -> AnalyticsParam.ScreensSources.Intro
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.tap.features.scanfails
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.card.ScanFailsRequester
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
@ -13,7 +14,7 @@ class ScanFailsRequesterProxy @Inject constructor() : ScanFailsRequester {
val componentRequester = MutableStateFlow<ScanFailsRequester?>(null)
override suspend fun show(source: ScanFailsRequester.Source): ScanFailsRequester.Result {
override suspend fun show(source: AnalyticsParam.ScreensSources): ScanFailsRequester.Result {
return withTimeout(timeMillis = 1000) {
componentRequester.filterNotNull().first()
}.show(source)

View file

@ -2,7 +2,9 @@ package com.tangem.tap.features.scanfails.di
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.Model
import com.tangem.domain.card.ScanFailsCounter
import com.tangem.domain.card.ScanFailsRequester
import com.tangem.tap.domain.scanCard.DefaultScanFailsCounter
import com.tangem.tap.features.scanfails.ScanFailsComponent
import com.tangem.tap.features.scanfails.ScanFailsModel
import com.tangem.tap.features.scanfails.ScanFailsRequesterProxy
@ -29,4 +31,8 @@ internal interface ScanFailsModule {
@Binds
@Singleton
fun bindRequester(impl: ScanFailsRequesterProxy): ScanFailsRequester
@Binds
@Singleton
fun bindScanFailsCounter(impl: DefaultScanFailsCounter): ScanFailsCounter
}

View file

@ -0,0 +1,138 @@
package com.tangem.tap.domain.scanCard
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.card.ScanFailsRequester
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultScanFailsCounterTest {
private val scanFailsRequester = mockk<ScanFailsRequester>()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val appScope = object : AppCoroutineScope {
override val coroutineContext = dispatchers.main
}
private lateinit var counter: DefaultScanFailsCounter
@BeforeEach
fun setup() {
clearMocks(scanFailsRequester)
counter = DefaultScanFailsCounter(
scanFailsRequester = scanFailsRequester,
appScope = appScope,
)
}
@Test
fun `single user cancellation does not show dialog`() = runTest {
// Arrange
val source = AnalyticsParam.ScreensSources.Main
coEvery { scanFailsRequester.show(source) } returns ScanFailsRequester.Result.Dismissed
// Act
counter.onScanFailure(isUserCancelled = true, source = source)
// Assert
coVerify(exactly = 0) { scanFailsRequester.show(source) }
}
@Test
fun `two consecutive user cancellations show dialog`() = runTest {
// Arrange
val source = AnalyticsParam.ScreensSources.Main
coEvery { scanFailsRequester.show(source) } returns ScanFailsRequester.Result.Dismissed
// Act
counter.onScanFailure(isUserCancelled = true, source = source)
counter.onScanFailure(isUserCancelled = true, source = source)
// Assert
coVerify(exactly = 1) { scanFailsRequester.show(source) }
}
@Test
fun `non-cancelled failure resets counter`() = runTest {
// Arrange
val source = AnalyticsParam.ScreensSources.Main
coEvery { scanFailsRequester.show(source) } returns ScanFailsRequester.Result.Dismissed
// Act
counter.onScanFailure(isUserCancelled = true, source = source)
counter.onScanFailure(isUserCancelled = false, source = source)
counter.onScanFailure(isUserCancelled = true, source = source)
// Assert
coVerify(exactly = 0) { scanFailsRequester.show(source) }
}
@Test
fun `reset clears counter`() = runTest {
// Arrange
val source = AnalyticsParam.ScreensSources.Main
coEvery { scanFailsRequester.show(source) } returns ScanFailsRequester.Result.Dismissed
// Act
counter.onScanFailure(isUserCancelled = true, source = source)
counter.reset()
counter.onScanFailure(isUserCancelled = true, source = source)
// Assert
coVerify(exactly = 0) { scanFailsRequester.show(source) }
}
@Test
fun `after reset two new cancellations show dialog again`() = runTest {
// Arrange
val source = AnalyticsParam.ScreensSources.SignIn
coEvery { scanFailsRequester.show(source) } returns ScanFailsRequester.Result.Dismissed
// Act
counter.onScanFailure(isUserCancelled = true, source = source)
counter.onScanFailure(isUserCancelled = true, source = source)
counter.reset()
counter.onScanFailure(isUserCancelled = true, source = source)
counter.onScanFailure(isUserCancelled = true, source = source)
// Assert
coVerify(exactly = 2) { scanFailsRequester.show(source) }
}
@Test
fun `dialog receives correct source`() = runTest {
// Arrange
val source = AnalyticsParam.ScreensSources.Settings
coEvery { scanFailsRequester.show(source) } returns ScanFailsRequester.Result.Dismissed
// Act
counter.onScanFailure(isUserCancelled = true, source = source)
counter.onScanFailure(isUserCancelled = true, source = source)
// Assert
coVerify(exactly = 1) { scanFailsRequester.show(source) }
}
@Test
fun `third consecutive cancellation also triggers dialog`() = runTest {
// Arrange
val source = AnalyticsParam.ScreensSources.Intro
coEvery { scanFailsRequester.show(source) } returns ScanFailsRequester.Result.Dismissed
// Act
counter.onScanFailure(isUserCancelled = true, source = source)
counter.onScanFailure(isUserCancelled = true, source = source)
counter.onScanFailure(isUserCancelled = true, source = source)
// Assert
coVerify(exactly = 2) { scanFailsRequester.show(source) }
}
}