Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-23 18:04:31 +04:00
parent ad65a62b3f
commit 97a13da0d7
19 changed files with 176 additions and 56 deletions

View file

@ -125,8 +125,9 @@ internal class AppStartupGateComponent @AssistedInject constructor(
AppUpdateState.ForceUpdate -> ForceUpdateComponent.Mode.Force AppUpdateState.ForceUpdate -> ForceUpdateComponent.Mode.Force
AppUpdateState.Brick -> ForceUpdateComponent.Mode.Brick AppUpdateState.Brick -> ForceUpdateComponent.Mode.Brick
AppUpdateState.OsTooOld -> ForceUpdateComponent.Mode.OsTooOld AppUpdateState.OsTooOld -> ForceUpdateComponent.Mode.OsTooOld
AppUpdateState.OptionalUpdate -> ForceUpdateComponent.Mode.Optional AppUpdateState.OptionalUpdate,
AppUpdateState.NoUpdate -> null AppUpdateState.NoUpdate,
-> null
} }
private sealed interface GateConfig { private sealed interface GateConfig {

View file

@ -8,6 +8,8 @@ import com.tangem.domain.appupdate.repository.AppUpdateRepository
import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.info.AppInfoProvider import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class GetAppUpdateStateUseCase( class GetAppUpdateStateUseCase(
private val repository: AppUpdateRepository, private val repository: AppUpdateRepository,
@ -17,11 +19,21 @@ class GetAppUpdateStateUseCase(
/** /**
* Instant decision computed from the cached thresholds and the current app/OS version. No network. * Instant decision computed from the cached thresholds and the current app/OS version. No network.
* Records the optional update as shown when it decides to show it (24h throttle). Never throws * Records the optional update as shown as it decides to show it (24h throttle) used by the startup gate.
* any failure resolves to [AppUpdateState.NoUpdate] so the initial navigation is never blocked. * Never throws any failure resolves to [AppUpdateState.NoUpdate] so the initial navigation is never blocked.
*/ */
suspend fun getCached(): AppUpdateState = runSuspendCatching { suspend fun getCached(): AppUpdateState = getCachedState(recordOptionalShown = true)
resolve(freshCachedInfoOrNull(), recordOptionalShown = true)
/**
* Cache-only update state as a cold [Flow], with the optional-update throttle disabled so a
* non-dismissible banner stays visible while the optional update is relevant. No network, no side effects.
*/
fun getBannerStateFlow(): Flow<AppUpdateState> = flow {
emit(getCachedState(recordOptionalShown = false))
}
private suspend fun getCachedState(recordOptionalShown: Boolean): AppUpdateState = runSuspendCatching {
resolve(freshCachedInfoOrNull(), recordOptionalShown = recordOptionalShown)
}.getOrElse { error -> }.getOrElse { error ->
TangemLogger.e("Unable to resolve cached app update state", error) TangemLogger.e("Unable to resolve cached app update state", error)
AppUpdateState.NoUpdate AppUpdateState.NoUpdate
@ -65,7 +77,7 @@ class GetAppUpdateStateUseCase(
val minSupportedVersion = info.minSupportedVersion?.let(AppVersion::parseOrNull) val minSupportedVersion = info.minSupportedVersion?.let(AppVersion::parseOrNull)
if (minSupportedVersion != null && if (minSupportedVersion != null &&
appVersion <= minSupportedVersion && appVersion < minSupportedVersion &&
isEscapable(latestVersion, minSupportedVersion) isEscapable(latestVersion, minSupportedVersion)
) { ) {
return blockingStateFor(info.minSupportedOSVersion, deviceOsVersion, AppUpdateState.OsTooOld) return blockingStateFor(info.minSupportedOSVersion, deviceOsVersion, AppUpdateState.OsTooOld)

View file

@ -15,6 +15,7 @@ import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -78,9 +79,9 @@ internal class GetAppUpdateStateUseCaseTest {
} }
@Test @Test
fun `GIVEN app at min supported and OS ok WHEN getCached THEN ForceUpdate`() = runTest { fun `GIVEN app below min supported and OS ok WHEN getCached THEN ForceUpdate`() = runTest {
givenCached( givenCached(
appVersion = "5.0", appVersion = "4.9",
osVersion = "14", osVersion = "14",
info = info(minSupportedVersion = "5.0", minSupportedOSVersion = "10", latestVersion = "5.1"), info = info(minSupportedVersion = "5.0", minSupportedOSVersion = "10", latestVersion = "5.1"),
) )
@ -89,9 +90,20 @@ internal class GetAppUpdateStateUseCaseTest {
} }
@Test @Test
fun `GIVEN app at min supported and OS too old WHEN getCached THEN OsTooOld`() = runTest { fun `GIVEN app exactly at min supported WHEN getCached THEN not force and optional`() = runTest {
givenCached( givenCached(
appVersion = "5.0", appVersion = "5.0",
osVersion = "14",
info = info(minSupportedVersion = "5.0", minSupportedOSVersion = "10", latestVersion = "5.1"),
)
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
}
@Test
fun `GIVEN app below min supported and OS too old WHEN getCached THEN OsTooOld`() = runTest {
givenCached(
appVersion = "4.9",
osVersion = "9", osVersion = "9",
info = info(minSupportedVersion = "5.0", minSupportedOSVersion = "10", latestVersion = "5.1"), info = info(minSupportedVersion = "5.0", minSupportedOSVersion = "10", latestVersion = "5.1"),
) )
@ -165,6 +177,16 @@ internal class GetAppUpdateStateUseCaseTest {
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate) assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
} }
@Test
fun `GIVEN optional shown recently WHEN getBannerStateFlow THEN OptionalUpdate without throttle`() = runTest {
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
coEvery { repository.getOptionalUpdateShown() } returns
OptionalUpdateShown(version = "5.37", shownAtMillis = NOW)
assertThat(useCase.getBannerStateFlow().first()).isEqualTo(AppUpdateState.OptionalUpdate)
coVerify(exactly = 0) { repository.setOptionalUpdateShown(any()) }
}
@Test @Test
fun `GIVEN all thresholds null WHEN getCached THEN NoUpdate`() = runTest { fun `GIVEN all thresholds null WHEN getCached THEN NoUpdate`() = runTest {
givenCached(info = info()) givenCached(info = info())

View file

@ -63,6 +63,10 @@ sealed interface FeedbackEmailType {
override val walletMetaInfo: WalletMetaInfo? = null override val walletMetaInfo: WalletMetaInfo? = null
} }
data object AppUpdateProblem : FeedbackEmailType {
override val walletMetaInfo: WalletMetaInfo? = null
}
sealed class Visa : FeedbackEmailType { sealed class Visa : FeedbackEmailType {
abstract val customerId: String abstract val customerId: String

View file

@ -94,6 +94,7 @@ class SendFeedbackEmailUseCase(
is FeedbackEmailType.PreActivatedWallet, is FeedbackEmailType.PreActivatedWallet,
is FeedbackEmailType.CardAttestationFailed, is FeedbackEmailType.CardAttestationFailed,
is FeedbackEmailType.BiometricsAuthenticationFailed, is FeedbackEmailType.BiometricsAuthenticationFailed,
is FeedbackEmailType.AppUpdateProblem,
is FeedbackEmailType.Visa.Dispute, is FeedbackEmailType.Visa.Dispute,
is FeedbackEmailType.Visa.DisputeV2, is FeedbackEmailType.Visa.DisputeV2,
is FeedbackEmailType.Visa.FeatureIsBeta, is FeedbackEmailType.Visa.FeatureIsBeta,

View file

@ -32,6 +32,7 @@ class EmailMessageBodyResolver(
is FeedbackEmailType.ScanningProblem, is FeedbackEmailType.ScanningProblem,
is FeedbackEmailType.CardAttestationFailed, is FeedbackEmailType.CardAttestationFailed,
is FeedbackEmailType.BiometricsAuthenticationFailed, is FeedbackEmailType.BiometricsAuthenticationFailed,
is FeedbackEmailType.AppUpdateProblem,
-> addPhoneInfoBody() -> addPhoneInfoBody()
is FeedbackEmailType.Visa.Activation -> addUserRequestBody(type.walletMetaInfo) is FeedbackEmailType.Visa.Activation -> addUserRequestBody(type.walletMetaInfo)
is FeedbackEmailType.Visa.DirectUserRequest -> addUserRequestBody(type.walletMetaInfo) is FeedbackEmailType.Visa.DirectUserRequest -> addUserRequestBody(type.walletMetaInfo)

View file

@ -30,6 +30,7 @@ internal class EmailMessageTitleResolver(private val resources: Resources) {
is FeedbackEmailType.Visa.KycRejected, is FeedbackEmailType.Visa.KycRejected,
is FeedbackEmailType.PreActivatedWallet, is FeedbackEmailType.PreActivatedWallet,
is FeedbackEmailType.BackupProblem, is FeedbackEmailType.BackupProblem,
is FeedbackEmailType.AppUpdateProblem,
-> R.string.feedback_preface_support -> R.string.feedback_preface_support
is FeedbackEmailType.RateCanBeBetter -> R.string.feedback_preface_rate_negative is FeedbackEmailType.RateCanBeBetter -> R.string.feedback_preface_rate_negative
is FeedbackEmailType.ScanningProblem -> R.string.feedback_preface_scan_failed is FeedbackEmailType.ScanningProblem -> R.string.feedback_preface_scan_failed

View file

@ -40,6 +40,7 @@ internal class EmailSubjectResolver(private val resources: Resources) {
} }
FeedbackEmailType.CardAttestationFailed -> "Card attestation failed" FeedbackEmailType.CardAttestationFailed -> "Card attestation failed"
FeedbackEmailType.BiometricsAuthenticationFailed -> "Biometrics authentication failed" FeedbackEmailType.BiometricsAuthenticationFailed -> "Biometrics authentication failed"
FeedbackEmailType.AppUpdateProblem -> resources.getStringSafe(R.string.feedback_subject_support_tangem)
is FeedbackEmailType.Visa.Activation -> "[Visa] [Activation] {auto-filled subject}" is FeedbackEmailType.Visa.Activation -> "[Visa] [Activation] {auto-filled subject}"
is FeedbackEmailType.Visa.DirectUserRequest -> "[Visa] {auto-filled subject}" is FeedbackEmailType.Visa.DirectUserRequest -> "[Visa] {auto-filled subject}"
is FeedbackEmailType.Visa.FailedIssueCard -> "[Visa] {auto-filled subject}" is FeedbackEmailType.Visa.FailedIssueCard -> "[Visa] {auto-filled subject}"

View file

@ -9,5 +9,5 @@ interface ForceUpdateComponent : ComposableContentComponent {
data class Params(val mode: Mode) data class Params(val mode: Mode)
enum class Mode { Force, Brick, OsTooOld, Optional } enum class Mode { Force, Brick, OsTooOld }
} }

View file

@ -33,6 +33,8 @@ dependencies {
/** Domain modules */ /** Domain modules */
implementation(projects.domain.appUpdate) implementation(projects.domain.appUpdate)
implementation(projects.domain.feedback)
implementation(projects.domain.feedback.models)
/** DI */ /** DI */
implementation(deps.hilt.android) implementation(deps.hilt.android)

View file

@ -7,6 +7,8 @@ import com.tangem.core.navigation.url.AppStoreOpener
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appupdate.model.AppUpdateState import com.tangem.domain.appupdate.model.AppUpdateState
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.features.forceupdate.ForceUpdateComponent import com.tangem.features.forceupdate.ForceUpdateComponent
import com.tangem.features.forceupdate.ForceUpdateContinuation import com.tangem.features.forceupdate.ForceUpdateContinuation
import com.tangem.features.forceupdate.impl.R import com.tangem.features.forceupdate.impl.R
@ -25,6 +27,7 @@ internal class ForceUpdateModel @Inject constructor(
private val appStoreOpener: AppStoreOpener, private val appStoreOpener: AppStoreOpener,
private val forceUpdateContinuation: ForceUpdateContinuation, private val forceUpdateContinuation: ForceUpdateContinuation,
private val getAppUpdateStateUseCase: GetAppUpdateStateUseCase, private val getAppUpdateStateUseCase: GetAppUpdateStateUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
) : Model() { ) : Model() {
@ -43,36 +46,24 @@ internal class ForceUpdateModel @Inject constructor(
accent = Accent.Red, accent = Accent.Red,
title = resourceReference(R.string.force_update_warning_title), title = resourceReference(R.string.force_update_warning_title),
description = resourceReference(R.string.force_update_warning_message), description = resourceReference(R.string.force_update_warning_message),
isBlocking = true,
onUpdateClick = ::onUpdateClick, onUpdateClick = ::onUpdateClick,
onLaterClick = null, onSupportClick = ::onSupportClick,
)
ForceUpdateComponent.Mode.Optional -> ForceUpdateUM(
mode = mode,
accent = Accent.Yellow,
title = resourceReference(R.string.force_update_warning_title),
description = resourceReference(R.string.force_update_warning_message),
isBlocking = false,
onUpdateClick = ::onUpdateClick,
onLaterClick = ::onLaterClick,
) )
ForceUpdateComponent.Mode.Brick -> ForceUpdateUM( ForceUpdateComponent.Mode.Brick -> ForceUpdateUM(
mode = mode, mode = mode,
accent = Accent.Red, accent = Accent.Red,
title = resourceReference(R.string.force_update_brick_title), title = resourceReference(R.string.force_update_brick_title),
description = resourceReference(R.string.force_update_brick_description), description = resourceReference(R.string.force_update_brick_description),
isBlocking = true,
onUpdateClick = null, onUpdateClick = null,
onLaterClick = null, onSupportClick = ::onSupportClick,
) )
ForceUpdateComponent.Mode.OsTooOld -> ForceUpdateUM( ForceUpdateComponent.Mode.OsTooOld -> ForceUpdateUM(
mode = mode, mode = mode,
accent = Accent.Red, accent = Accent.Red,
title = resourceReference(R.string.force_update_os_title), title = resourceReference(R.string.force_update_os_title),
description = resourceReference(R.string.force_update_os_description), description = resourceReference(R.string.force_update_os_description),
isBlocking = true,
onUpdateClick = null, onUpdateClick = null,
onLaterClick = null, onSupportClick = null,
) )
} }
@ -80,8 +71,10 @@ internal class ForceUpdateModel @Inject constructor(
appStoreOpener.openStorePage() appStoreOpener.openStorePage()
} }
private fun onLaterClick() { private fun onSupportClick() {
forceUpdateContinuation.dismiss() modelScope.launch {
sendFeedbackEmailUseCase(type = FeedbackEmailType.AppUpdateProblem)
}
} }
/** /**
@ -105,7 +98,8 @@ internal class ForceUpdateModel @Inject constructor(
AppUpdateState.ForceUpdate -> ForceUpdateComponent.Mode.Force AppUpdateState.ForceUpdate -> ForceUpdateComponent.Mode.Force
AppUpdateState.Brick -> ForceUpdateComponent.Mode.Brick AppUpdateState.Brick -> ForceUpdateComponent.Mode.Brick
AppUpdateState.OsTooOld -> ForceUpdateComponent.Mode.OsTooOld AppUpdateState.OsTooOld -> ForceUpdateComponent.Mode.OsTooOld
AppUpdateState.OptionalUpdate -> ForceUpdateComponent.Mode.Optional AppUpdateState.OptionalUpdate,
AppUpdateState.NoUpdate -> null AppUpdateState.NoUpdate,
-> null
} }
} }

View file

@ -1,7 +1,6 @@
package com.tangem.features.forceupdate.impl.ui package com.tangem.features.forceupdate.impl.ui
import android.content.res.Configuration import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -39,10 +38,6 @@ import com.tangem.features.forceupdate.impl.ui.state.ForceUpdateUM.Accent
@Composable @Composable
internal fun ForceUpdateContent(state: ForceUpdateUM, modifier: Modifier = Modifier) { internal fun ForceUpdateContent(state: ForceUpdateUM, modifier: Modifier = Modifier) {
BackHandler(enabled = !state.isBlocking) {
state.onLaterClick?.invoke()
}
val accentColor = when (state.accent) { val accentColor = when (state.accent) {
Accent.Red -> TangemTheme.colors3.icon.accent.red Accent.Red -> TangemTheme.colors3.icon.accent.red
Accent.Yellow -> TangemTheme.colors3.icon.accent.yellow Accent.Yellow -> TangemTheme.colors3.icon.accent.yellow
@ -111,6 +106,14 @@ private fun Buttons(state: ForceUpdateUM, modifier: Modifier = Modifier) {
modifier = modifier, modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
state.onSupportClick?.let { onClick ->
TangemButton(
modifier = Modifier.fillMaxWidth(),
variant = TangemButton.Variant.Secondary,
text = resourceReference(R.string.common_contact_support),
onClick = onClick,
)
}
state.onUpdateClick?.let { onClick -> state.onUpdateClick?.let { onClick ->
TangemButton( TangemButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -119,14 +122,6 @@ private fun Buttons(state: ForceUpdateUM, modifier: Modifier = Modifier) {
onClick = onClick, onClick = onClick,
) )
} }
state.onLaterClick?.let { onClick ->
TangemButton(
modifier = Modifier.fillMaxWidth(),
variant = TangemButton.Variant.Secondary,
text = resourceReference(R.string.common_later),
onClick = onClick,
)
}
} }
} }
@ -154,9 +149,8 @@ private fun PreviewForce() {
accent = Accent.Red, accent = Accent.Red,
title = TextReference.Str("Update Required"), title = TextReference.Str("Update Required"),
description = TextReference.Str("Please update the application to the latest version."), description = TextReference.Str("Please update the application to the latest version."),
isBlocking = true,
onUpdateClick = {}, onUpdateClick = {},
onLaterClick = null, onSupportClick = {},
), ),
) )
} }
@ -165,17 +159,16 @@ private fun PreviewForce() {
@Preview(showBackground = true, widthDp = 360, heightDp = 780) @Preview(showBackground = true, widthDp = 360, heightDp = 780)
@Preview(showBackground = true, widthDp = 360, heightDp = 780, uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(showBackground = true, widthDp = 360, heightDp = 780, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
private fun PreviewOptional() { private fun PreviewBrick() {
TangemThemePreviewRedesign { TangemThemePreviewRedesign {
ForceUpdateContent( ForceUpdateContent(
state = ForceUpdateUM( state = ForceUpdateUM(
mode = ForceUpdateComponent.Mode.Optional, mode = ForceUpdateComponent.Mode.Brick,
accent = Accent.Yellow, accent = Accent.Red,
title = TextReference.Str("Update Required"), title = TextReference.Str("Device not supported"),
description = TextReference.Str("Please update the application to the latest version."), description = TextReference.Str("This device can't run the OS version Tangem needs."),
isBlocking = false, onUpdateClick = null,
onUpdateClick = {}, onSupportClick = {},
onLaterClick = {},
), ),
) )
} }

View file

@ -10,9 +10,8 @@ internal data class ForceUpdateUM(
val accent: Accent, val accent: Accent,
val title: TextReference, val title: TextReference,
val description: TextReference, val description: TextReference,
val isBlocking: Boolean,
val onUpdateClick: (() -> Unit)?, val onUpdateClick: (() -> Unit)?,
val onLaterClick: (() -> Unit)?, val onSupportClick: (() -> Unit)?,
) { ) {
enum class Accent { Red, Yellow } enum class Accent { Red, Yellow }

View file

@ -80,6 +80,7 @@ dependencies {
api(projects.domain.analytics) api(projects.domain.analytics)
api(projects.domain.appCurrency) api(projects.domain.appCurrency)
api(projects.domain.appTheme) api(projects.domain.appTheme)
api(projects.domain.appUpdate)
api(projects.domain.assetsdiscovery) api(projects.domain.assetsdiscovery)
api(projects.domain.balanceHiding) api(projects.domain.balanceHiding)
api(projects.domain.card) api(projects.domain.card)

View file

@ -12,6 +12,7 @@ import com.tangem.core.analytics.models.event.AssetsDiscoveryAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.review.ReviewManager import com.tangem.core.navigation.review.ReviewManager
import com.tangem.core.navigation.url.AppStoreOpener
import com.tangem.domain.assetsdiscovery.usecase.AcknowledgeAssetsDiscoveryCompletionUseCase import com.tangem.domain.assetsdiscovery.usecase.AcknowledgeAssetsDiscoveryCompletionUseCase
import com.tangem.domain.card.SetCardWasScannedUseCase import com.tangem.domain.card.SetCardWasScannedUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
@ -73,6 +74,8 @@ internal interface WalletWarningsClickIntents {
fun onSupportClick() fun onSupportClick()
fun onSoftUpdateClick()
fun onBackupErrorClick() fun onBackupErrorClick()
fun onNoteMigrationButtonClick(url: String) fun onNoteMigrationButtonClick(url: String)
@ -111,6 +114,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val appStoreOpener: AppStoreOpener,
private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher, private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher,
@ -248,6 +252,10 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
} }
} }
override fun onSoftUpdateClick() {
appStoreOpener.openStorePage()
}
override fun onBackupErrorClick() { override fun onBackupErrorClick() {
val userWallet = getSelectedUserWallet() ?: return val userWallet = getSelectedUserWallet() ?: return

View file

@ -145,6 +145,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
WalletNotificationUM.TangemPayUnreachable, WalletNotificationUM.TangemPayUnreachable,
is WalletNotificationUM.YieldBoostPromo, is WalletNotificationUM.YieldBoostPromo,
is WalletNotificationUM.AssetsDiscoveryCompleted, is WalletNotificationUM.AssetsDiscoveryCompleted,
is WalletNotificationUM.SoftUpdateAvailable,
-> null -> null
} }
} }

View file

@ -5,6 +5,8 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.ds.message.TangemMessageEffect import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.appupdate.model.AppUpdateState
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase
import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.CardTypesResolver
@ -49,6 +51,7 @@ internal class GetWalletNotificationsFactory @Inject constructor(
private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase, private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase,
private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase, private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase,
private val observeAssetsDiscoveryUseCase: ObserveAssetsDiscoveryUseCase, private val observeAssetsDiscoveryUseCase: ObserveAssetsDiscoveryUseCase,
private val getAppUpdateStateUseCase: GetAppUpdateStateUseCase,
) { ) {
fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotificationUM>> { fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotificationUM>> {
val cardTypesResolver = (userWallet as? UserWallet.Cold)?.scanResponse?.cardTypesResolver val cardTypesResolver = (userWallet as? UserWallet.Cold)?.scanResponse?.cardTypesResolver
@ -68,7 +71,8 @@ internal class GetWalletNotificationsFactory @Inject constructor(
flow2 = isNeedToBackupUseCase(userWallet.walletId).distinctUntilChanged(), flow2 = isNeedToBackupUseCase(userWallet.walletId).distinctUntilChanged(),
flow3 = getAccessCodeSkippedUseCase(userWallet.walletId).distinctUntilChanged(), flow3 = getAccessCodeSkippedUseCase(userWallet.walletId).distinctUntilChanged(),
flow4 = assetsDiscoveryProgressFlow, flow4 = assetsDiscoveryProgressFlow,
) { accountList, isNeedToBackup, shouldAccessCodeSkipped, assetsDiscoveryProgress -> flow5 = getAppUpdateStateUseCase.getBannerStateFlow(),
) { accountList, isNeedToBackup, shouldAccessCodeSkipped, assetsDiscoveryProgress, appUpdateState ->
val totalFiatBalance = accountList.totalFiatBalance val totalFiatBalance = accountList.totalFiatBalance
val flattenCurrencies = accountList.flattenCurrencies() val flattenCurrencies = accountList.flattenCurrencies()
@ -126,10 +130,22 @@ internal class GetWalletNotificationsFactory @Inject constructor(
walletClickIntents = clickIntents, walletClickIntents = clickIntents,
) )
} }
addSoftUpdateBanner(appUpdateState, clickIntents)
}.sortedBy { it.type.ordinal }.toImmutableList() }.sortedBy { it.type.ordinal }.toImmutableList()
} }
} }
private fun MutableList<WalletNotificationUM>.addSoftUpdateBanner(
appUpdateState: AppUpdateState,
clickIntents: WalletClickIntents,
) {
addIf(
element = WalletNotificationUM.SoftUpdateAvailable(onUpdateClick = clickIntents::onSoftUpdateClick),
condition = appUpdateState == AppUpdateState.OptionalUpdate,
)
}
private fun MutableList<WalletNotificationUM>.addUsedOutdatedDataNotification(totalFiatBalance: TotalFiatBalance) { private fun MutableList<WalletNotificationUM>.addUsedOutdatedDataNotification(totalFiatBalance: TotalFiatBalance) {
addIf( addIf(
element = WalletNotificationUM.UsedOutdatedData, element = WalletNotificationUM.UsedOutdatedData,

View file

@ -352,6 +352,27 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t
), ),
type = WalletNotificationType.Warning, type = WalletNotificationType.Warning,
) )
data class SoftUpdateAvailable(val onUpdateClick: () -> Unit) : WalletNotificationUM(
messageUM = TangemMessageUM(
id = "SoftUpdateAvailableNotification",
title = resourceReference(id = CoreResR.string.force_update_banner_title),
subtitle = resourceReference(id = CoreResR.string.force_update_banner_message),
iconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_attention_default_24,
tintReference = { TangemTheme.colors2.graphic.status.attention },
),
messageEffect = TangemMessageEffect.Warning,
buttonsUM = persistentListOf(
TangemMessageButtonUM(
text = resourceReference(id = CoreResR.string.force_update_action),
type = TangemButtonType.Primary,
onClick = onUpdateClick,
),
),
),
type = WalletNotificationType.Warning,
)
// endregion // endregion
// region Promo // region Promo

View file

@ -5,6 +5,8 @@ import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.appupdate.model.AppUpdateState
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase
import com.tangem.domain.card.CardTypesResolver import com.tangem.domain.card.CardTypesResolver
@ -56,6 +58,7 @@ internal class GetWalletNotificationsFactoryTest {
private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase = mockk() private val getAccessCodeSkippedUseCase: GetAccessCodeSkippedUseCase = mockk()
private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase = mockk() private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase = mockk()
private val observeAssetsDiscoveryUseCase: ObserveAssetsDiscoveryUseCase = mockk() private val observeAssetsDiscoveryUseCase: ObserveAssetsDiscoveryUseCase = mockk()
private val getAppUpdateStateUseCase: GetAppUpdateStateUseCase = mockk()
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk() private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
private val clickIntents: WalletClickIntents = mockk(relaxed = true) private val clickIntents: WalletClickIntents = mockk(relaxed = true)
@ -73,6 +76,7 @@ internal class GetWalletNotificationsFactoryTest {
getAccessCodeSkippedUseCase = getAccessCodeSkippedUseCase, getAccessCodeSkippedUseCase = getAccessCodeSkippedUseCase,
hasSingleWalletSignedHashesUseCase = hasSingleWalletSignedHashesUseCase, hasSingleWalletSignedHashesUseCase = hasSingleWalletSignedHashesUseCase,
observeAssetsDiscoveryUseCase = observeAssetsDiscoveryUseCase, observeAssetsDiscoveryUseCase = observeAssetsDiscoveryUseCase,
getAppUpdateStateUseCase = getAppUpdateStateUseCase,
) )
@BeforeEach @BeforeEach
@ -85,6 +89,7 @@ internal class GetWalletNotificationsFactoryTest {
getAccessCodeSkippedUseCase, getAccessCodeSkippedUseCase,
hasSingleWalletSignedHashesUseCase, hasSingleWalletSignedHashesUseCase,
observeAssetsDiscoveryUseCase, observeAssetsDiscoveryUseCase,
getAppUpdateStateUseCase,
singleAccountStatusListSupplier, singleAccountStatusListSupplier,
clickIntents, clickIntents,
coldResolver, coldResolver,
@ -100,6 +105,7 @@ internal class GetWalletNotificationsFactoryTest {
every { isNeedToBackupUseCase(any()) } returns flowOf(false) every { isNeedToBackupUseCase(any()) } returns flowOf(false)
every { getAccessCodeSkippedUseCase(any()) } returns flowOf(true) every { getAccessCodeSkippedUseCase(any()) } returns flowOf(true)
every { observeAssetsDiscoveryUseCase(any()) } returns flowOf(AssetsDiscoveryProgress.Idle) every { observeAssetsDiscoveryUseCase(any()) } returns flowOf(AssetsDiscoveryProgress.Idle)
every { getAppUpdateStateUseCase.getBannerStateFlow() } returns flowOf(AppUpdateState.NoUpdate)
every { hasSingleWalletSignedHashesUseCase(any(), any()) } returns flowOf(false) every { hasSingleWalletSignedHashesUseCase(any(), any()) } returns flowOf(false)
// Balance is loaded and non-zero, so both the outdated-data and add-funds banners stay hidden. // Balance is loaded and non-zero, so both the outdated-data and add-funds banners stay hidden.
stubAccountStatusList(balance = LOADED_NON_ZERO) stubAccountStatusList(balance = LOADED_NON_ZERO)
@ -648,6 +654,42 @@ internal class GetWalletNotificationsFactoryTest {
} }
// endregion // endregion
// region SoftUpdate
@Test
fun `GIVEN optional update WHEN create THEN soft-update banner is shown`() = runTest {
// Arrange
every { getAppUpdateStateUseCase.getBannerStateFlow() } returns flowOf(AppUpdateState.OptionalUpdate)
// Act
val result = factory.create(coldWallet, clickIntents).first()
// Assert
assertThat(result.any { it is WalletNotificationUM.SoftUpdateAvailable }).isTrue()
}
@ParameterizedTest
@MethodSource("provideNonOptionalUpdateStates")
fun `GIVEN non-optional update state WHEN create THEN soft-update banner is hidden`(
state: AppUpdateState,
) = runTest {
// Arrange
every { getAppUpdateStateUseCase.getBannerStateFlow() } returns flowOf(state)
// Act
val result = factory.create(coldWallet, clickIntents).first()
// Assert
assertThat(result.none { it is WalletNotificationUM.SoftUpdateAvailable }).isTrue()
}
private fun provideNonOptionalUpdateStates() = listOf(
AppUpdateState.NoUpdate,
AppUpdateState.ForceUpdate,
AppUpdateState.Brick,
AppUpdateState.OsTooOld,
)
// endregion
// region TangemPay warnings // region TangemPay warnings
@ParameterizedTest @ParameterizedTest
@MethodSource("provideTangemPayModels") @MethodSource("provideTangemPayModels")