Updated on 2026-08-14
This commit is contained in:
parent
ad65a62b3f
commit
97a13da0d7
19 changed files with 176 additions and 56 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 = {},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue