Updated on 2026-08-14
This commit is contained in:
parent
26bba893ff
commit
51e650c360
47 changed files with 1628 additions and 73 deletions
|
|
@ -124,6 +124,7 @@ dependencies {
|
||||||
implementation(projects.domain.wallets)
|
implementation(projects.domain.wallets)
|
||||||
implementation(projects.domain.wallets.models)
|
implementation(projects.domain.wallets.models)
|
||||||
implementation(projects.domain.settings)
|
implementation(projects.domain.settings)
|
||||||
|
implementation(projects.domain.appUpdate)
|
||||||
implementation(projects.domain.tokens)
|
implementation(projects.domain.tokens)
|
||||||
implementation(projects.domain.tokens.models)
|
implementation(projects.domain.tokens.models)
|
||||||
implementation(projects.domain.txhistory)
|
implementation(projects.domain.txhistory)
|
||||||
|
|
@ -199,6 +200,7 @@ dependencies {
|
||||||
implementation(projects.data.card)
|
implementation(projects.data.card)
|
||||||
implementation(projects.data.common)
|
implementation(projects.data.common)
|
||||||
implementation(projects.data.settings)
|
implementation(projects.data.settings)
|
||||||
|
implementation(projects.data.appUpdate)
|
||||||
implementation(projects.data.tokens)
|
implementation(projects.data.tokens)
|
||||||
implementation(projects.data.assetsdiscovery)
|
implementation(projects.data.assetsdiscovery)
|
||||||
implementation(projects.data.txhistory)
|
implementation(projects.data.txhistory)
|
||||||
|
|
@ -264,6 +266,8 @@ dependencies {
|
||||||
implementation(projects.features.details.impl)
|
implementation(projects.features.details.impl)
|
||||||
implementation(projects.features.disclaimer.api)
|
implementation(projects.features.disclaimer.api)
|
||||||
implementation(projects.features.disclaimer.impl)
|
implementation(projects.features.disclaimer.impl)
|
||||||
|
implementation(projects.features.forceUpdate.api)
|
||||||
|
implementation(projects.features.forceUpdate.impl)
|
||||||
implementation(projects.features.pushNotifications.api)
|
implementation(projects.features.pushNotifications.api)
|
||||||
implementation(projects.features.pushNotifications.impl)
|
implementation(projects.features.pushNotifications.impl)
|
||||||
implementation(projects.features.pushNotificationSettings.api)
|
implementation(projects.features.pushNotificationSettings.api)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import com.tangem.core.navigation.deeplink.DeeplinkLauncher
|
||||||
import com.tangem.core.navigation.finisher.AppFinisher
|
import com.tangem.core.navigation.finisher.AppFinisher
|
||||||
import com.tangem.core.navigation.settings.SettingsManager
|
import com.tangem.core.navigation.settings.SettingsManager
|
||||||
import com.tangem.core.navigation.share.ShareManager
|
import com.tangem.core.navigation.share.ShareManager
|
||||||
|
import com.tangem.core.navigation.url.AppStoreOpener
|
||||||
|
import com.tangem.core.navigation.url.DefaultAppStoreOpener
|
||||||
import com.tangem.core.navigation.url.UrlOpener
|
import com.tangem.core.navigation.url.UrlOpener
|
||||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||||
import com.tangem.tap.common.finisher.AndroidAppFinisher
|
import com.tangem.tap.common.finisher.AndroidAppFinisher
|
||||||
|
|
@ -35,6 +37,10 @@ internal interface UtilsModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun bindAppInfoProvider(impl: DefaultAppInfoProvider): AppInfoProvider
|
fun bindAppInfoProvider(impl: DefaultAppInfoProvider): AppInfoProvider
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
fun bindAppStoreOpener(impl: DefaultAppStoreOpener): AppStoreOpener
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.tangem.tap.features.root
|
||||||
|
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
internal class DefaultRootWarningContinuation @Inject constructor() : RootWarningContinuation {
|
||||||
|
|
||||||
|
private val dismissals = Channel<Unit>(capacity = Channel.CONFLATED)
|
||||||
|
|
||||||
|
override suspend fun awaitDismiss() {
|
||||||
|
dismissals.receive()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dismiss() {
|
||||||
|
dismissals.trySend(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,68 +1,40 @@
|
||||||
package com.tangem.tap.features.root
|
package com.tangem.tap.features.root
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.arkivanov.essenty.instancekeeper.getOrCreateSimple
|
|
||||||
import com.tangem.core.decompose.context.AppComponentContext
|
import com.tangem.core.decompose.context.AppComponentContext
|
||||||
import com.tangem.core.decompose.factory.ComponentFactory
|
import com.tangem.core.decompose.factory.ComponentFactory
|
||||||
import com.tangem.core.ui.components.DialogFullScreen
|
import com.tangem.core.ui.components.DialogFullScreen
|
||||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||||
import com.tangem.domain.settings.repositories.SettingsRepository
|
|
||||||
import com.tangem.security.DeviceSecurityInfoProvider
|
|
||||||
import com.tangem.security.isSecurityExposed
|
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen root-detected security warning. Presentational only — its visibility is controlled by the
|
||||||
|
* startup gate (via a childSlot); "Continue" resolves [RootWarningContinuation]. Whether it should be shown
|
||||||
|
* at all (and marking it as shown) is decided by the gate.
|
||||||
|
*/
|
||||||
@Suppress("UnusedPrivateProperty")
|
@Suppress("UnusedPrivateProperty")
|
||||||
class RootDetectedWarningComponent @AssistedInject constructor(
|
internal class RootDetectedWarningComponent @AssistedInject constructor(
|
||||||
@Assisted appComponentContext: AppComponentContext,
|
@Assisted appComponentContext: AppComponentContext,
|
||||||
@Assisted params: Unit,
|
@Assisted params: Unit,
|
||||||
private val securityInfoProvider: DeviceSecurityInfoProvider,
|
private val rootWarningContinuation: RootWarningContinuation,
|
||||||
private val settingsRepository: SettingsRepository,
|
|
||||||
) : AppComponentContext by appComponentContext, ComposableContentComponent {
|
) : AppComponentContext by appComponentContext, ComposableContentComponent {
|
||||||
|
|
||||||
private val isShown = instanceKeeper.getOrCreateSimple { MutableStateFlow(false) }
|
|
||||||
|
|
||||||
suspend fun shouldShowWarning(): Boolean {
|
|
||||||
return settingsRepository.isRootDetectedWarningShown().not() && securityInfoProvider.isSecurityExposed()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun tryToShowWarningAndWaitContinuation() {
|
|
||||||
if (isShown.value) return
|
|
||||||
|
|
||||||
if (shouldShowWarning()) {
|
|
||||||
isShown.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
isShown.first { it == false } // Wait until the warning is dismissed
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun Content(modifier: Modifier) {
|
override fun Content(modifier: Modifier) {
|
||||||
val isShownState by isShown.collectAsStateWithLifecycle()
|
DialogFullScreen(onDismissRequest = {}) {
|
||||||
|
RootDetectedWarningContent(
|
||||||
if (isShownState) {
|
modifier = modifier,
|
||||||
DialogFullScreen(onDismissRequest = {}) {
|
onContinueClick = remember(this) { ::onContinueClick },
|
||||||
RootDetectedWarningContent(
|
)
|
||||||
modifier = modifier,
|
|
||||||
onContinueClick = remember(this) { ::onContinueClick },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onContinueClick() {
|
private fun onContinueClick() {
|
||||||
componentScope.launch {
|
rootWarningContinuation.dismiss()
|
||||||
settingsRepository.setRootDetectedWarningShown(true)
|
|
||||||
isShown.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.tangem.tap.features.root
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumes the startup gate after the root-detected security warning is dismissed.
|
||||||
|
*
|
||||||
|
* The gate awaits [awaitDismiss] while the warning is shown, and the screen calls [dismiss] on "Continue".
|
||||||
|
*/
|
||||||
|
interface RootWarningContinuation {
|
||||||
|
|
||||||
|
suspend fun awaitDismiss()
|
||||||
|
|
||||||
|
fun dismiss()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package com.tangem.tap.features.root.di
|
||||||
|
|
||||||
|
import com.tangem.tap.features.root.DefaultRootWarningContinuation
|
||||||
|
import com.tangem.tap.features.root.RootWarningContinuation
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
internal interface RootModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
fun bindRootWarningContinuation(impl: DefaultRootWarningContinuation): RootWarningContinuation
|
||||||
|
}
|
||||||
|
|
@ -43,7 +43,7 @@ internal fun RootContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
wcContent: @Composable (modifier: Modifier) -> Unit,
|
wcContent: @Composable (modifier: Modifier) -> Unit,
|
||||||
hotAccessCodeContent: @Composable (modifier: Modifier) -> Unit,
|
hotAccessCodeContent: @Composable (modifier: Modifier) -> Unit,
|
||||||
rootDetectedWarningContent: @Composable (modifier: Modifier) -> Unit,
|
startupGateContent: @Composable (modifier: Modifier) -> Unit,
|
||||||
scanFailsContent: @Composable (modifier: Modifier) -> Unit,
|
scanFailsContent: @Composable (modifier: Modifier) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -82,7 +82,7 @@ internal fun RootContent(
|
||||||
|
|
||||||
hotAccessCodeContent(Modifier.fillMaxSize())
|
hotAccessCodeContent(Modifier.fillMaxSize())
|
||||||
|
|
||||||
rootDetectedWarningContent(Modifier.fillMaxSize())
|
startupGateContent(Modifier.fillMaxSize())
|
||||||
|
|
||||||
scanFailsContent(Modifier.fillMaxSize())
|
scanFailsContent(Modifier.fillMaxSize())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,13 +55,13 @@ import com.tangem.sdk.api.BackupServiceHolder
|
||||||
import com.tangem.tap.common.SnackbarHandler
|
import com.tangem.tap.common.SnackbarHandler
|
||||||
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerReferralParamsHandler
|
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerReferralParamsHandler
|
||||||
import com.tangem.tap.features.hot.TangemHotSDKProxy
|
import com.tangem.tap.features.hot.TangemHotSDKProxy
|
||||||
import com.tangem.tap.features.root.RootDetectedWarningComponent
|
|
||||||
import com.tangem.tap.features.scanfails.ScanFailsComponent
|
import com.tangem.tap.features.scanfails.ScanFailsComponent
|
||||||
import com.tangem.tap.features.scanfails.ScanFailsRequesterProxy
|
import com.tangem.tap.features.scanfails.ScanFailsRequesterProxy
|
||||||
import com.tangem.tap.routing.RootContent
|
import com.tangem.tap.routing.RootContent
|
||||||
import com.tangem.tap.routing.component.RoutingComponent
|
import com.tangem.tap.routing.component.RoutingComponent
|
||||||
import com.tangem.tap.routing.component.RoutingComponent.Child
|
import com.tangem.tap.routing.component.RoutingComponent.Child
|
||||||
import com.tangem.tap.routing.configurator.AppRouterConfig
|
import com.tangem.tap.routing.configurator.AppRouterConfig
|
||||||
|
import com.tangem.tap.routing.startup.AppStartupGateComponent
|
||||||
import com.tangem.tap.routing.utils.ChildFactory
|
import com.tangem.tap.routing.utils.ChildFactory
|
||||||
import com.tangem.tap.routing.utils.DeepLinkFactory
|
import com.tangem.tap.routing.utils.DeepLinkFactory
|
||||||
import com.tangem.utils.logging.TangemLogger
|
import com.tangem.utils.logging.TangemLogger
|
||||||
|
|
@ -86,7 +86,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
|
||||||
private val tangemHotSDKProxy: TangemHotSDKProxy,
|
private val tangemHotSDKProxy: TangemHotSDKProxy,
|
||||||
private val hotAccessCodeRequestComponentFactory: HotAccessCodeRequestComponent.Factory,
|
private val hotAccessCodeRequestComponentFactory: HotAccessCodeRequestComponent.Factory,
|
||||||
private val hotAccessCodeRequesterProxy: HotWalletPasswordRequesterProxy,
|
private val hotAccessCodeRequesterProxy: HotWalletPasswordRequesterProxy,
|
||||||
private val rootDetectedWarningComponentFactory: RootDetectedWarningComponent.Factory,
|
private val appStartupGateComponentFactory: AppStartupGateComponent.Factory,
|
||||||
private val userWalletsListRepository: UserWalletsListRepository,
|
private val userWalletsListRepository: UserWalletsListRepository,
|
||||||
private val cardRepository: CardRepository,
|
private val cardRepository: CardRepository,
|
||||||
private val onboardingRepository: OnboardingRepository,
|
private val onboardingRepository: OnboardingRepository,
|
||||||
|
|
@ -117,9 +117,8 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
|
||||||
.create(child("hotAccessCodeRequestComponent"), Unit)
|
.create(child("hotAccessCodeRequestComponent"), Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val rootDetectedWarningComponent: RootDetectedWarningComponent by lazy {
|
private val appStartupGateComponent: AppStartupGateComponent by lazy {
|
||||||
rootDetectedWarningComponentFactory
|
appStartupGateComponentFactory.create(child("appStartupGate"))
|
||||||
.create(child("rootDetectedWarningComponent"), Unit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val scanFailsComponent: ScanFailsComponent by lazy {
|
private val scanFailsComponent: ScanFailsComponent by lazy {
|
||||||
|
|
@ -175,40 +174,37 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
|
||||||
|
|
||||||
private fun initializeInitialNavigation() {
|
private fun initializeInitialNavigation() {
|
||||||
if (initialStack.isNullOrEmpty()) {
|
if (initialStack.isNullOrEmpty()) {
|
||||||
componentScope.launch {
|
componentScope.launch { resolveAndNavigate() }
|
||||||
val initialRoute = resolveInitialRoute()
|
|
||||||
if (rootDetectedWarningComponent.shouldShowWarning()) {
|
|
||||||
launch(dispatchers.main) {
|
|
||||||
rootDetectedWarningComponent.tryToShowWarningAndWaitContinuation()
|
|
||||||
router.replaceAll(initialRoute)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
router.replaceAll(initialRoute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolveInitialRoute(): AppRoute {
|
private suspend fun resolveAndNavigate() {
|
||||||
|
appStartupGateComponent.await()
|
||||||
|
navigateToStartRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun navigateToStartRoute() {
|
||||||
|
val initialRoute = resolveStartRoute()
|
||||||
|
onInitialRouteResolved(initialRoute)
|
||||||
|
router.replaceAll(initialRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveStartRoute(): AppRoute {
|
||||||
val userWallets = userWalletsListRepository.userWalletsSync()
|
val userWallets = userWalletsListRepository.userWalletsSync()
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
userWallets.isEmpty() -> navigateForEmptyWallets()
|
userWallets.isEmpty() -> navigateForEmptyWallets()
|
||||||
userWallets.any { it.isLocked } -> {
|
userWallets.any { it.isLocked } -> AppRoute.Welcome(launchMode = launchMode)
|
||||||
AppRoute.Welcome(
|
else -> AppRoute.Wallet
|
||||||
launchMode = launchMode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
trackSignInEvent()
|
|
||||||
AppRoute.Wallet
|
|
||||||
}
|
|
||||||
}.also {
|
|
||||||
appRouterConfig.initializedState.value = true
|
|
||||||
checkForUnfinishedBackup()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun onInitialRouteResolved(route: AppRoute) {
|
||||||
|
appRouterConfig.initializedState.value = true
|
||||||
|
if (route is AppRoute.Wallet) trackSignInEvent()
|
||||||
|
checkForUnfinishedBackup()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun navigateForEmptyWallets(): AppRoute {
|
private suspend fun navigateForEmptyWallets(): AppRoute {
|
||||||
val isHotWalletOnboardingEnabled = featureTogglesManager.isFeatureEnabled(
|
val isHotWalletOnboardingEnabled = featureTogglesManager.isFeatureEnabled(
|
||||||
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
|
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
|
||||||
|
|
@ -269,7 +265,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
wcContent = { wcRoutingComponent.Content(it) },
|
wcContent = { wcRoutingComponent.Content(it) },
|
||||||
hotAccessCodeContent = { hotAccessCodeRequestComponent.Content(it) },
|
hotAccessCodeContent = { hotAccessCodeRequestComponent.Content(it) },
|
||||||
rootDetectedWarningContent = { rootDetectedWarningComponent.Content(it) },
|
startupGateContent = { appStartupGateComponent.Content(it) },
|
||||||
scanFailsContent = { scanFailsComponent.Content(it) },
|
scanFailsContent = { scanFailsComponent.Content(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
package com.tangem.tap.routing.startup
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||||
|
import com.arkivanov.decompose.router.slot.ChildSlot
|
||||||
|
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||||
|
import com.arkivanov.decompose.router.slot.activate
|
||||||
|
import com.arkivanov.decompose.router.slot.childSlot
|
||||||
|
import com.arkivanov.decompose.router.slot.dismiss
|
||||||
|
import com.arkivanov.decompose.value.Value
|
||||||
|
import com.tangem.core.decompose.context.AppComponentContext
|
||||||
|
import com.tangem.core.decompose.context.childByContext
|
||||||
|
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||||
|
import com.tangem.domain.appupdate.model.AppUpdateState
|
||||||
|
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
|
||||||
|
import com.tangem.domain.settings.repositories.SettingsRepository
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateComponent
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateContinuation
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateFeatureToggles
|
||||||
|
import com.tangem.security.DeviceSecurityInfoProvider
|
||||||
|
import com.tangem.security.isSecurityExposed
|
||||||
|
import com.tangem.tap.features.root.RootDetectedWarningComponent
|
||||||
|
import com.tangem.tap.features.root.RootWarningContinuation
|
||||||
|
import com.tangem.tap.routing.configurator.AppRouterConfig
|
||||||
|
import com.tangem.utils.coroutines.runSuspendCatching
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the pre-start gates shown before the regular startup navigation — the force-update screen and the
|
||||||
|
* root-detected security warning — as interchangeable full-screen overlays in a single [childSlot].
|
||||||
|
* [await] runs them in order and returns when the app may proceed, so the routing component stays agnostic.
|
||||||
|
*/
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
internal class AppStartupGateComponent @AssistedInject constructor(
|
||||||
|
@Assisted context: AppComponentContext,
|
||||||
|
private val getAppUpdateStateUseCase: GetAppUpdateStateUseCase,
|
||||||
|
private val forceUpdateFeatureToggles: ForceUpdateFeatureToggles,
|
||||||
|
private val forceUpdateContinuation: ForceUpdateContinuation,
|
||||||
|
private val forceUpdateComponentFactory: ForceUpdateComponent.Factory,
|
||||||
|
private val rootDetectedWarningComponentFactory: RootDetectedWarningComponent.Factory,
|
||||||
|
private val rootWarningContinuation: RootWarningContinuation,
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val securityInfoProvider: DeviceSecurityInfoProvider,
|
||||||
|
private val appRouterConfig: AppRouterConfig,
|
||||||
|
) : AppComponentContext by context, ComposableContentComponent {
|
||||||
|
|
||||||
|
private val slotNavigation = SlotNavigation<GateConfig>()
|
||||||
|
|
||||||
|
private val slot: Value<ChildSlot<GateConfig, ComposableContentComponent>> = childSlot(
|
||||||
|
source = slotNavigation,
|
||||||
|
serializer = null,
|
||||||
|
handleBackButton = false,
|
||||||
|
childFactory = { config, childContext ->
|
||||||
|
when (config) {
|
||||||
|
is GateConfig.ForceUpdate -> forceUpdateComponentFactory.create(
|
||||||
|
context = childByContext(childContext),
|
||||||
|
params = ForceUpdateComponent.Params(mode = config.mode),
|
||||||
|
)
|
||||||
|
GateConfig.RootWarning -> rootDetectedWarningComponentFactory.create(
|
||||||
|
context = childByContext(childContext),
|
||||||
|
params = Unit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Runs the pre-start gates in order; returns when the app may proceed to normal startup. */
|
||||||
|
suspend fun await() {
|
||||||
|
awaitForceUpdate()
|
||||||
|
awaitRootWarning()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun awaitForceUpdate() {
|
||||||
|
val mode = runSuspendCatching { resolveForceUpdateMode() }
|
||||||
|
.onFailure { error -> TangemLogger.e("App update check failed, proceeding with normal startup", error) }
|
||||||
|
.getOrNull()
|
||||||
|
?: return
|
||||||
|
|
||||||
|
showGate(GateConfig.ForceUpdate(mode))
|
||||||
|
forceUpdateContinuation.awaitDismiss()
|
||||||
|
slotNavigation.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun awaitRootWarning() {
|
||||||
|
if (settingsRepository.isRootDetectedWarningShown() || !securityInfoProvider.isSecurityExposed()) return
|
||||||
|
|
||||||
|
showGate(GateConfig.RootWarning)
|
||||||
|
rootWarningContinuation.awaitDismiss()
|
||||||
|
settingsRepository.setRootDetectedWarningShown(true)
|
||||||
|
slotNavigation.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showGate(config: GateConfig) {
|
||||||
|
// The gate overlay is drawn on top of the splash, so mark navigation initialized to dismiss the splash.
|
||||||
|
appRouterConfig.initializedState.value = true
|
||||||
|
slotNavigation.activate(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveForceUpdateMode(): ForceUpdateComponent.Mode? {
|
||||||
|
if (!forceUpdateFeatureToggles.isForceUpdateEnabled) return null
|
||||||
|
|
||||||
|
val mode = getAppUpdateStateUseCase.getCached().toForceUpdateModeOrNull()
|
||||||
|
|
||||||
|
// The force-update screen re-checks on open, so a one-shot refresh is only needed when no screen is shown.
|
||||||
|
if (mode == null) {
|
||||||
|
componentScope.launch { getAppUpdateStateUseCase.refresh() }
|
||||||
|
}
|
||||||
|
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content(modifier: Modifier) {
|
||||||
|
val child by slot.subscribeAsState()
|
||||||
|
child.child?.instance?.Content(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AppUpdateState.toForceUpdateModeOrNull(): ForceUpdateComponent.Mode? = when (this) {
|
||||||
|
AppUpdateState.ForceUpdate -> ForceUpdateComponent.Mode.Force
|
||||||
|
AppUpdateState.Brick -> ForceUpdateComponent.Mode.Brick
|
||||||
|
AppUpdateState.OsTooOld -> ForceUpdateComponent.Mode.OsTooOld
|
||||||
|
AppUpdateState.OptionalUpdate -> ForceUpdateComponent.Mode.Optional
|
||||||
|
AppUpdateState.NoUpdate -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface GateConfig {
|
||||||
|
data class ForceUpdate(val mode: ForceUpdateComponent.Mode) : GateConfig
|
||||||
|
data object RootWarning : GateConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(context: AppComponentContext): AppStartupGateComponent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
"name": "AND_15901_STORIES_CONTAINER_ENABLED",
|
"name": "AND_15901_STORIES_CONTAINER_ENABLED",
|
||||||
"version": "undefined"
|
"version": "undefined"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "TWI_1322_FORCE_UPDATE_ENABLED",
|
||||||
|
"version": "undefined"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "NEW_CARD_SCANNING_ENABLED",
|
"name": "NEW_CARD_SCANNING_ENABLED",
|
||||||
"version": "undefined"
|
"version": "undefined"
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ interface TangemTechApi {
|
||||||
@GET("v1/geo")
|
@GET("v1/geo")
|
||||||
suspend fun getUserCountryCode(): GeoResponse
|
suspend fun getUserCountryCode(): GeoResponse
|
||||||
|
|
||||||
|
@GET("v1/application/versions")
|
||||||
|
suspend fun getApplicationVersions(): ApiResponse<ApplicationVersionsResponse>
|
||||||
|
|
||||||
@PUT("/v1/wallets/{walletId}/tokens")
|
@PUT("/v1/wallets/{walletId}/tokens")
|
||||||
suspend fun saveTokens(
|
suspend fun saveTokens(
|
||||||
@Path(value = "walletId") userId: String,
|
@Path(value = "walletId") userId: String,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.tangem.datasource.api.tangemTech.models
|
||||||
|
|
||||||
|
import com.squareup.moshi.Json
|
||||||
|
import com.squareup.moshi.JsonClass
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response of `GET v1/application/versions`. All fields are nullable — the backend may omit any, in
|
||||||
|
* which case the corresponding check is skipped.
|
||||||
|
*
|
||||||
|
* @property minSupportedVersion app version threshold for a mandatory update: if
|
||||||
|
* `installedVersion <= minSupportedVersion` the app must force-update (or show "update your OS"
|
||||||
|
* when [minSupportedOSVersion] is not met). Inclusive. E.g. "5.30".
|
||||||
|
* @property minSupportedOSVersion minimal device OS version required to install the update for the
|
||||||
|
* [minSupportedVersion] case: if `deviceOsVersion < minSupportedOSVersion` the device can't update
|
||||||
|
* and the "OS too old" screen is shown. Exclusive.
|
||||||
|
* @property criticalVersion app version threshold for a critical mandatory update: if
|
||||||
|
* `installedVersion <= criticalVersion` the app must force-update (or permanently "brick" when
|
||||||
|
* [criticalOSVersion] is not met). Inclusive.
|
||||||
|
* @property criticalOSVersion minimal device OS version required to install the critical update:
|
||||||
|
* if `deviceOsVersion < criticalOSVersion` the app is bricked (update impossible). Exclusive.
|
||||||
|
* @property latestVersion latest available app version: if `installedVersion < latestVersion`
|
||||||
|
* an optional update is offered. Exclusive. E.g. "5.40".
|
||||||
|
*/
|
||||||
|
@JsonClass(generateAdapter = true)
|
||||||
|
data class ApplicationVersionsResponse(
|
||||||
|
@Json(name = "minSupportedVersion") val minSupportedVersion: String?,
|
||||||
|
@Json(name = "minSupportedOSVersion") val minSupportedOSVersion: String?,
|
||||||
|
@Json(name = "criticalVersion") val criticalVersion: String?,
|
||||||
|
@Json(name = "criticalOSVersion") val criticalOSVersion: String?,
|
||||||
|
@Json(name = "latestVersion") val latestVersion: String?,
|
||||||
|
)
|
||||||
|
|
@ -33,6 +33,16 @@ object PreferencesKeys {
|
||||||
|
|
||||||
val APP_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "launchCount") }
|
val APP_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "launchCount") }
|
||||||
|
|
||||||
|
val LAST_OPTIONAL_UPDATE_SHOWN_VERSION_KEY by lazy {
|
||||||
|
stringPreferencesKey(name = "lastOptionalUpdateShownVersion")
|
||||||
|
}
|
||||||
|
|
||||||
|
val LAST_OPTIONAL_UPDATE_SHOWN_AT_KEY by lazy { longPreferencesKey(name = "lastOptionalUpdateShownAt") }
|
||||||
|
|
||||||
|
val CACHED_APP_VERSIONS_KEY by lazy { stringPreferencesKey(name = "cachedApplicationVersions") }
|
||||||
|
|
||||||
|
val CACHED_APP_VERSIONS_AT_KEY by lazy { longPreferencesKey(name = "cachedApplicationVersionsAt") }
|
||||||
|
|
||||||
val SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "showRatingDialogAtLaunchCount") }
|
val SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "showRatingDialogAtLaunchCount") }
|
||||||
|
|
||||||
val FUNDS_FOUND_DATE_KEY by lazy { longPreferencesKey(name = "fundsFoundDate") }
|
val FUNDS_FOUND_DATE_KEY by lazy { longPreferencesKey(name = "fundsFoundDate") }
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ android {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
|
// region Core modules
|
||||||
|
implementation(projects.core.utils)
|
||||||
|
// endregion
|
||||||
|
|
||||||
// region DI
|
// region DI
|
||||||
implementation(deps.hilt.android)
|
implementation(deps.hilt.android)
|
||||||
// endregion
|
// endregion
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.tangem.core.navigation.url
|
||||||
|
|
||||||
|
interface AppStoreOpener {
|
||||||
|
|
||||||
|
fun openStorePage()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package com.tangem.core.navigation.url
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.tangem.utils.buildConfig.AppConfigurationProvider
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class DefaultAppStoreOpener @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val appConfigurationProvider: AppConfigurationProvider,
|
||||||
|
) : AppStoreOpener {
|
||||||
|
|
||||||
|
override fun openStorePage() {
|
||||||
|
val storeUri: String
|
||||||
|
val webUrl: String
|
||||||
|
if (appConfigurationProvider.isHuawei()) {
|
||||||
|
storeUri = "$HUAWEI_STORE_SCHEME$STORE_PACKAGE_NAME"
|
||||||
|
webUrl = "$HUAWEI_WEB_URL$STORE_PACKAGE_NAME"
|
||||||
|
} else {
|
||||||
|
storeUri = "$GOOGLE_STORE_SCHEME$STORE_PACKAGE_NAME"
|
||||||
|
webUrl = "$GOOGLE_WEB_URL$STORE_PACKAGE_NAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
openUri(storeUri) || openUri(webUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openUri(uri: String): Boolean {
|
||||||
|
return try {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW, uri.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
TangemLogger.e("Unable to open store uri: $uri", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val STORE_PACKAGE_NAME = "com.tangem.wallet"
|
||||||
|
const val GOOGLE_STORE_SCHEME = "market://details?id="
|
||||||
|
const val GOOGLE_WEB_URL = "https://play.google.com/store/apps/details?id="
|
||||||
|
const val HUAWEI_STORE_SCHEME = "appmarket://details?id="
|
||||||
|
const val HUAWEI_WEB_URL = "https://appgallery.huawei.com/app/"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
data/app-update/.gitignore
vendored
Normal file
1
data/app-update/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
27
data/app-update/build.gradle.kts
Normal file
27
data/app-update/build.gradle.kts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
plugins {
|
||||||
|
alias(deps.plugins.android.library)
|
||||||
|
alias(deps.plugins.kotlin.android)
|
||||||
|
alias(deps.plugins.kotlin.kapt)
|
||||||
|
alias(deps.plugins.hilt.android)
|
||||||
|
id("configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.tangem.data.appupdate"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
implementation(projects.core.datasource)
|
||||||
|
implementation(projects.core.utils)
|
||||||
|
|
||||||
|
implementation(projects.domain.appUpdate)
|
||||||
|
|
||||||
|
implementation(deps.arrow.core)
|
||||||
|
implementation(deps.kotlin.coroutines)
|
||||||
|
|
||||||
|
// region DI
|
||||||
|
implementation(deps.hilt.android)
|
||||||
|
kapt(deps.hilt.kapt)
|
||||||
|
// endregion
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package com.tangem.data.appupdate
|
||||||
|
|
||||||
|
import arrow.core.Either
|
||||||
|
import com.tangem.datasource.api.common.response.getOrThrow
|
||||||
|
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||||
|
import com.tangem.datasource.api.tangemTech.models.ApplicationVersionsResponse
|
||||||
|
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||||
|
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||||
|
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
|
||||||
|
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
|
||||||
|
import com.tangem.datasource.local.preferences.utils.store
|
||||||
|
import com.tangem.datasource.local.preferences.utils.storeObject
|
||||||
|
import com.tangem.domain.appupdate.model.AppVersionInfo
|
||||||
|
import com.tangem.domain.appupdate.model.OptionalUpdateShown
|
||||||
|
import com.tangem.domain.appupdate.repository.AppUpdateRepository
|
||||||
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
internal class DefaultAppUpdateRepository(
|
||||||
|
private val tangemTechApi: TangemTechApi,
|
||||||
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
|
private val dispatchers: CoroutineDispatcherProvider,
|
||||||
|
private val currentTimeMillis: () -> Long = System::currentTimeMillis,
|
||||||
|
) : AppUpdateRepository {
|
||||||
|
|
||||||
|
override suspend fun getCachedAppVersionInfo(): AppVersionInfo? = appPreferencesStore
|
||||||
|
.getObjectSyncOrNull<ApplicationVersionsResponse>(PreferencesKeys.CACHED_APP_VERSIONS_KEY)
|
||||||
|
?.toDomain()
|
||||||
|
|
||||||
|
override suspend fun getCachedAppVersionTimestamp(): Long? =
|
||||||
|
appPreferencesStore.getSyncOrNull(PreferencesKeys.CACHED_APP_VERSIONS_AT_KEY)
|
||||||
|
|
||||||
|
override suspend fun refreshAppVersionInfo(): Either<Throwable, AppVersionInfo> = withContext(dispatchers.io) {
|
||||||
|
Either.catch {
|
||||||
|
val response = tangemTechApi.getApplicationVersions().getOrThrow()
|
||||||
|
appPreferencesStore.storeObject(PreferencesKeys.CACHED_APP_VERSIONS_KEY, response)
|
||||||
|
appPreferencesStore.store(PreferencesKeys.CACHED_APP_VERSIONS_AT_KEY, currentTimeMillis())
|
||||||
|
response.toDomain()
|
||||||
|
}.onLeft { error -> TangemLogger.e("Unable to fetch application versions", error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getOptionalUpdateShown(): OptionalUpdateShown? {
|
||||||
|
val version = appPreferencesStore.getSyncOrNull(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_VERSION_KEY)
|
||||||
|
?: return null
|
||||||
|
val shownAtMillis = appPreferencesStore.getSyncOrNull(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_AT_KEY)
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
return OptionalUpdateShown(version = version, shownAtMillis = shownAtMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setOptionalUpdateShown(shown: OptionalUpdateShown) {
|
||||||
|
appPreferencesStore.store(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_VERSION_KEY, shown.version)
|
||||||
|
appPreferencesStore.store(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_AT_KEY, shown.shownAtMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ApplicationVersionsResponse.toDomain() = AppVersionInfo(
|
||||||
|
minSupportedVersion = minSupportedVersion,
|
||||||
|
minSupportedOSVersion = minSupportedOSVersion,
|
||||||
|
criticalVersion = criticalVersion,
|
||||||
|
criticalOSVersion = criticalOSVersion,
|
||||||
|
latestVersion = latestVersion,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package com.tangem.data.appupdate.di
|
||||||
|
|
||||||
|
import com.tangem.data.appupdate.DefaultAppUpdateRepository
|
||||||
|
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||||
|
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||||
|
import com.tangem.domain.appupdate.repository.AppUpdateRepository
|
||||||
|
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
|
||||||
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import com.tangem.utils.info.AppInfoProvider
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
internal object AppUpdateDataModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAppUpdateRepository(
|
||||||
|
tangemTechApi: TangemTechApi,
|
||||||
|
appPreferencesStore: AppPreferencesStore,
|
||||||
|
dispatchers: CoroutineDispatcherProvider,
|
||||||
|
): AppUpdateRepository = DefaultAppUpdateRepository(
|
||||||
|
tangemTechApi = tangemTechApi,
|
||||||
|
appPreferencesStore = appPreferencesStore,
|
||||||
|
dispatchers = dispatchers,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideGetAppUpdateStateUseCase(
|
||||||
|
repository: AppUpdateRepository,
|
||||||
|
appInfoProvider: AppInfoProvider,
|
||||||
|
): GetAppUpdateStateUseCase = GetAppUpdateStateUseCase(
|
||||||
|
repository = repository,
|
||||||
|
appInfoProvider = appInfoProvider,
|
||||||
|
)
|
||||||
|
}
|
||||||
1
domain/app-update/.gitignore
vendored
Normal file
1
domain/app-update/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
19
domain/app-update/build.gradle.kts
Normal file
19
domain/app-update/build.gradle.kts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
plugins {
|
||||||
|
alias(deps.plugins.kotlin.jvm)
|
||||||
|
id("configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.core.utils)
|
||||||
|
|
||||||
|
implementation(deps.arrow.core)
|
||||||
|
implementation(deps.kotlin.coroutines)
|
||||||
|
|
||||||
|
// region Test
|
||||||
|
testImplementation(deps.test.junit5)
|
||||||
|
testImplementation(deps.test.mockk)
|
||||||
|
testImplementation(deps.test.truth)
|
||||||
|
testImplementation(deps.test.coroutine)
|
||||||
|
testRuntimeOnly(deps.test.junit5.engine)
|
||||||
|
// endregion
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.tangem.domain.appupdate.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of checking whether the application needs an update.
|
||||||
|
*/
|
||||||
|
enum class AppUpdateState {
|
||||||
|
|
||||||
|
/** Update is mandatory — a blocking screen with an "Update now" button must be shown. */
|
||||||
|
ForceUpdate,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update is mandatory but impossible on this device (OS too old for the critical version) —
|
||||||
|
* a permanently blocking "brick" screen must be shown.
|
||||||
|
*/
|
||||||
|
Brick,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update is mandatory but the device OS is too old for the min-supported version — a blocking
|
||||||
|
* "update your OS" screen must be shown.
|
||||||
|
*/
|
||||||
|
OsTooOld,
|
||||||
|
|
||||||
|
/** Update is available but optional — a dismissible screen may be shown. */
|
||||||
|
OptionalUpdate,
|
||||||
|
|
||||||
|
/** No update is required. */
|
||||||
|
NoUpdate,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package com.tangem.domain.appupdate.model
|
||||||
|
|
||||||
|
internal class AppVersion private constructor(
|
||||||
|
private val major: Int,
|
||||||
|
private val minor: Int,
|
||||||
|
private val fix: Int,
|
||||||
|
) : Comparable<AppVersion> {
|
||||||
|
|
||||||
|
override fun compareTo(other: AppVersion): Int {
|
||||||
|
major.compareTo(other.major).let { if (it != 0) return it }
|
||||||
|
minor.compareTo(other.minor).let { if (it != 0) return it }
|
||||||
|
return fix.compareTo(other.fix)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DELIMITER = "."
|
||||||
|
private const val MAJOR = 0
|
||||||
|
private const val MINOR = 1
|
||||||
|
private const val FIX = 2
|
||||||
|
|
||||||
|
fun parseOrNull(value: String): AppVersion? {
|
||||||
|
// Drop build-type/pre-release suffixes ("6.1-internal", "1.0.0-SNAPSHOT") before parsing.
|
||||||
|
val parts = value.trim().substringBefore('-').substringBefore('+').split(DELIMITER)
|
||||||
|
|
||||||
|
val major = parts.getOrNull(MAJOR)?.toIntOrNull() ?: return null
|
||||||
|
val minor = parts.getOrNull(MINOR).toVersionPartOrNull() ?: return null
|
||||||
|
val fix = parts.getOrNull(FIX).toVersionPartOrNull() ?: return null
|
||||||
|
|
||||||
|
return AppVersion(major = major, minor = minor, fix = fix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.toVersionPartOrNull(): Int? = when (this) {
|
||||||
|
null -> 0
|
||||||
|
else -> toIntOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package com.tangem.domain.appupdate.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend-driven update policy. All fields are nullable; a null threshold skips its check.
|
||||||
|
*
|
||||||
|
* @property minSupportedVersion mandatory-update threshold (inclusive): `installedVersion <= minSupportedVersion`
|
||||||
|
* @property minSupportedOSVersion OS threshold for the min-supported case (exclusive): `deviceOsVersion < it` -> OS too old
|
||||||
|
* @property criticalVersion critical-update threshold (inclusive): `installedVersion <= criticalVersion`
|
||||||
|
* @property criticalOSVersion OS threshold for the critical case (exclusive): `deviceOsVersion < it` -> brick
|
||||||
|
* @property latestVersion optional-update threshold (exclusive): `installedVersion < latestVersion`
|
||||||
|
*/
|
||||||
|
data class AppVersionInfo(
|
||||||
|
val minSupportedVersion: String?,
|
||||||
|
val minSupportedOSVersion: String?,
|
||||||
|
val criticalVersion: String?,
|
||||||
|
val criticalOSVersion: String?,
|
||||||
|
val latestVersion: String?,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.tangem.domain.appupdate.model
|
||||||
|
|
||||||
|
data class OptionalUpdateShown(
|
||||||
|
val version: String,
|
||||||
|
val shownAtMillis: Long,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.tangem.domain.appupdate.repository
|
||||||
|
|
||||||
|
import arrow.core.Either
|
||||||
|
import com.tangem.domain.appupdate.model.AppVersionInfo
|
||||||
|
import com.tangem.domain.appupdate.model.OptionalUpdateShown
|
||||||
|
|
||||||
|
interface AppUpdateRepository {
|
||||||
|
|
||||||
|
/** Last cached version thresholds, or `null` if nothing has been fetched yet. No network. */
|
||||||
|
suspend fun getCachedAppVersionInfo(): AppVersionInfo?
|
||||||
|
|
||||||
|
/** Wall-clock time (millis) of the last successful fetch, or `null` if nothing has been fetched yet. */
|
||||||
|
suspend fun getCachedAppVersionTimestamp(): Long?
|
||||||
|
|
||||||
|
/** Fetches fresh thresholds and, on success, overwrites the cache (and its timestamp). */
|
||||||
|
suspend fun refreshAppVersionInfo(): Either<Throwable, AppVersionInfo>
|
||||||
|
|
||||||
|
suspend fun getOptionalUpdateShown(): OptionalUpdateShown?
|
||||||
|
|
||||||
|
suspend fun setOptionalUpdateShown(shown: OptionalUpdateShown)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
package com.tangem.domain.appupdate.usecase
|
||||||
|
|
||||||
|
import com.tangem.domain.appupdate.model.AppUpdateState
|
||||||
|
import com.tangem.domain.appupdate.model.AppVersion
|
||||||
|
import com.tangem.domain.appupdate.model.AppVersionInfo
|
||||||
|
import com.tangem.domain.appupdate.model.OptionalUpdateShown
|
||||||
|
import com.tangem.domain.appupdate.repository.AppUpdateRepository
|
||||||
|
import com.tangem.utils.coroutines.runSuspendCatching
|
||||||
|
import com.tangem.utils.info.AppInfoProvider
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
|
|
||||||
|
class GetAppUpdateStateUseCase(
|
||||||
|
private val repository: AppUpdateRepository,
|
||||||
|
private val appInfoProvider: AppInfoProvider,
|
||||||
|
private val currentTimeMillis: () -> Long = System::currentTimeMillis,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 —
|
||||||
|
* any failure resolves to [AppUpdateState.NoUpdate] so the initial navigation is never blocked.
|
||||||
|
*/
|
||||||
|
suspend fun getCached(): AppUpdateState = runSuspendCatching {
|
||||||
|
resolve(freshCachedInfoOrNull(), recordOptionalShown = true)
|
||||||
|
}.getOrElse { error ->
|
||||||
|
TangemLogger.e("Unable to resolve cached app update state", error)
|
||||||
|
AppUpdateState.NoUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches fresh thresholds (overwriting the cache) and re-evaluates. Falls back to the cache on a
|
||||||
|
* network error. Does not record the optional update — used for background and on-screen refreshes.
|
||||||
|
* Never throws — any failure resolves to [AppUpdateState.NoUpdate].
|
||||||
|
*/
|
||||||
|
suspend fun refresh(): AppUpdateState = runSuspendCatching {
|
||||||
|
val info = repository.refreshAppVersionInfo().getOrNull() ?: freshCachedInfoOrNull()
|
||||||
|
resolve(info, recordOptionalShown = false)
|
||||||
|
}.getOrElse { error ->
|
||||||
|
TangemLogger.e("Unable to resolve app update state", error)
|
||||||
|
AppUpdateState.NoUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached thresholds, but only while they are still fresh. A cache older than [CACHE_TTL_MILLIS] is
|
||||||
|
* ignored so a permanently unreachable backend can't keep the user blocked forever — a successful
|
||||||
|
* fetch is required at least once per TTL window to keep a blocking threshold in effect.
|
||||||
|
*/
|
||||||
|
private suspend fun freshCachedInfoOrNull(): AppVersionInfo? {
|
||||||
|
val cachedAt = repository.getCachedAppVersionTimestamp() ?: return null
|
||||||
|
if (currentTimeMillis() - cachedAt > CACHE_TTL_MILLIS) return null
|
||||||
|
return repository.getCachedAppVersionInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolve(info: AppVersionInfo?, recordOptionalShown: Boolean): AppUpdateState {
|
||||||
|
info ?: return AppUpdateState.NoUpdate
|
||||||
|
|
||||||
|
val appVersion = AppVersion.parseOrNull(appInfoProvider.appVersion) ?: return AppUpdateState.NoUpdate
|
||||||
|
val deviceOsVersion = AppVersion.parseOrNull(appInfoProvider.osVersion)
|
||||||
|
val latestVersion = info.latestVersion?.let(AppVersion::parseOrNull)
|
||||||
|
|
||||||
|
val criticalVersion = info.criticalVersion?.let(AppVersion::parseOrNull)
|
||||||
|
if (criticalVersion != null && appVersion <= criticalVersion && isEscapable(latestVersion, criticalVersion)) {
|
||||||
|
return blockingStateFor(info.criticalOSVersion, deviceOsVersion, AppUpdateState.Brick)
|
||||||
|
}
|
||||||
|
|
||||||
|
val minSupportedVersion = info.minSupportedVersion?.let(AppVersion::parseOrNull)
|
||||||
|
if (minSupportedVersion != null &&
|
||||||
|
appVersion <= minSupportedVersion &&
|
||||||
|
isEscapable(latestVersion, minSupportedVersion)
|
||||||
|
) {
|
||||||
|
return blockingStateFor(info.minSupportedOSVersion, deviceOsVersion, AppUpdateState.OsTooOld)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.latestVersion != null && latestVersion != null && appVersion < latestVersion) {
|
||||||
|
return resolveOptionalUpdate(info.latestVersion, recordOptionalShown)
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppUpdateState.NoUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A blocking threshold is honored only if the advertised latest version is strictly above it — i.e.
|
||||||
|
* updating actually clears the block. A threshold no installable version can satisfy is a backend
|
||||||
|
* misconfiguration and is ignored.
|
||||||
|
*/
|
||||||
|
private fun isEscapable(latestVersion: AppVersion?, threshold: AppVersion): Boolean =
|
||||||
|
latestVersion != null && latestVersion > threshold
|
||||||
|
|
||||||
|
private suspend fun resolveOptionalUpdate(latestVersion: String, recordOptionalShown: Boolean): AppUpdateState {
|
||||||
|
if (!recordOptionalShown) return AppUpdateState.OptionalUpdate
|
||||||
|
|
||||||
|
val shown = repository.getOptionalUpdateShown()
|
||||||
|
val isThrottled = shown != null &&
|
||||||
|
shown.version == latestVersion &&
|
||||||
|
currentTimeMillis() - shown.shownAtMillis < OPTIONAL_UPDATE_INTERVAL_MILLIS
|
||||||
|
|
||||||
|
if (isThrottled) return AppUpdateState.NoUpdate
|
||||||
|
|
||||||
|
repository.setOptionalUpdateShown(
|
||||||
|
OptionalUpdateShown(version = latestVersion, shownAtMillis = currentTimeMillis()),
|
||||||
|
)
|
||||||
|
return AppUpdateState.OptionalUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun blockingStateFor(
|
||||||
|
requiredOsVersion: String?,
|
||||||
|
deviceOsVersion: AppVersion?,
|
||||||
|
osTooOldState: AppUpdateState,
|
||||||
|
): AppUpdateState {
|
||||||
|
val requiredOs = requiredOsVersion?.let(AppVersion::parseOrNull)
|
||||||
|
val cannotUpdate = requiredOs != null && deviceOsVersion != null && deviceOsVersion < requiredOs
|
||||||
|
return if (cannotUpdate) osTooOldState else AppUpdateState.ForceUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val OPTIONAL_UPDATE_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
|
||||||
|
const val CACHE_TTL_MILLIS = 24L * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
package com.tangem.domain.appupdate
|
||||||
|
|
||||||
|
import arrow.core.left
|
||||||
|
import arrow.core.right
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.domain.appupdate.model.AppUpdateState
|
||||||
|
import com.tangem.domain.appupdate.model.AppVersionInfo
|
||||||
|
import com.tangem.domain.appupdate.model.OptionalUpdateShown
|
||||||
|
import com.tangem.domain.appupdate.repository.AppUpdateRepository
|
||||||
|
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
|
||||||
|
import com.tangem.utils.info.AppInfoProvider
|
||||||
|
import io.mockk.Runs
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.just
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
internal class GetAppUpdateStateUseCaseTest {
|
||||||
|
|
||||||
|
private val repository = mockk<AppUpdateRepository>()
|
||||||
|
private val appInfoProvider = mockk<AppInfoProvider>()
|
||||||
|
private val useCase = GetAppUpdateStateUseCase(repository, appInfoProvider, currentTimeMillis = { NOW })
|
||||||
|
|
||||||
|
private fun givenCached(appVersion: String = "5.0", osVersion: String = "14", info: AppVersionInfo?) {
|
||||||
|
every { appInfoProvider.appVersion } returns appVersion
|
||||||
|
every { appInfoProvider.osVersion } returns osVersion
|
||||||
|
coEvery { repository.getCachedAppVersionInfo() } returns info
|
||||||
|
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW
|
||||||
|
coEvery { repository.getOptionalUpdateShown() } returns null
|
||||||
|
coEvery { repository.setOptionalUpdateShown(any()) } just Runs
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun givenRefresh(appVersion: String = "5.0", osVersion: String = "14", info: AppVersionInfo) {
|
||||||
|
every { appInfoProvider.appVersion } returns appVersion
|
||||||
|
every { appInfoProvider.osVersion } returns osVersion
|
||||||
|
coEvery { repository.refreshAppVersionInfo() } returns info.right()
|
||||||
|
coEvery { repository.getOptionalUpdateShown() } returns null
|
||||||
|
coEvery { repository.setOptionalUpdateShown(any()) } just Runs
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun info(
|
||||||
|
minSupportedVersion: String? = null,
|
||||||
|
minSupportedOSVersion: String? = null,
|
||||||
|
criticalVersion: String? = null,
|
||||||
|
criticalOSVersion: String? = null,
|
||||||
|
latestVersion: String? = null,
|
||||||
|
) = AppVersionInfo(
|
||||||
|
minSupportedVersion = minSupportedVersion,
|
||||||
|
minSupportedOSVersion = minSupportedOSVersion,
|
||||||
|
criticalVersion = criticalVersion,
|
||||||
|
criticalOSVersion = criticalOSVersion,
|
||||||
|
latestVersion = latestVersion,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN app at critical version and OS ok WHEN getCached THEN ForceUpdate`() = runTest {
|
||||||
|
givenCached(
|
||||||
|
appVersion = "5.0",
|
||||||
|
osVersion = "14",
|
||||||
|
info = info(criticalVersion = "5.0", criticalOSVersion = "10", latestVersion = "5.1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.ForceUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN app at critical version and OS too old WHEN getCached THEN Brick`() = runTest {
|
||||||
|
givenCached(
|
||||||
|
appVersion = "5.0",
|
||||||
|
osVersion = "9",
|
||||||
|
info = info(criticalVersion = "5.0", criticalOSVersion = "10", latestVersion = "5.1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.Brick)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN app at min supported and OS ok WHEN getCached THEN ForceUpdate`() = runTest {
|
||||||
|
givenCached(
|
||||||
|
appVersion = "5.0",
|
||||||
|
osVersion = "14",
|
||||||
|
info = info(minSupportedVersion = "5.0", minSupportedOSVersion = "10", latestVersion = "5.1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.ForceUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN app at min supported and OS too old WHEN getCached THEN OsTooOld`() = runTest {
|
||||||
|
givenCached(
|
||||||
|
appVersion = "5.0",
|
||||||
|
osVersion = "9",
|
||||||
|
info = info(minSupportedVersion = "5.0", minSupportedOSVersion = "10", latestVersion = "5.1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OsTooOld)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN critical above latest WHEN getCached THEN not blocking and degraded to optional`() = runTest {
|
||||||
|
givenCached(appVersion = "5.20", info = info(criticalVersion = "9.99", latestVersion = "5.41"))
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN min supported above latest WHEN getCached THEN not blocking and degraded to optional`() = runTest {
|
||||||
|
givenCached(appVersion = "5.20", info = info(minSupportedVersion = "9.99", latestVersion = "5.41"))
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN blocking threshold but no latest WHEN getCached THEN ignored as NoUpdate`() = runTest {
|
||||||
|
givenCached(appVersion = "5.0", info = info(criticalVersion = "5.0", criticalOSVersion = "10"))
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN app below latest and not shown before WHEN getCached THEN OptionalUpdate is recorded`() = runTest {
|
||||||
|
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
repository.setOptionalUpdateShown(OptionalUpdateShown(version = "5.37", shownAtMillis = NOW))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN app at latest WHEN getCached THEN NoUpdate`() = runTest {
|
||||||
|
givenCached(appVersion = "5.37", info = info(latestVersion = "5.37"))
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN optional shown for same version within 24h WHEN getCached THEN NoUpdate`() = runTest {
|
||||||
|
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
|
||||||
|
coEvery { repository.getOptionalUpdateShown() } returns
|
||||||
|
OptionalUpdateShown(version = "5.37", shownAtMillis = NOW - DAY_MILLIS + 1)
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
|
||||||
|
coVerify(exactly = 0) { repository.setOptionalUpdateShown(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN optional shown for same version over 24h ago WHEN getCached THEN OptionalUpdate`() = runTest {
|
||||||
|
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
|
||||||
|
coEvery { repository.getOptionalUpdateShown() } returns
|
||||||
|
OptionalUpdateShown(version = "5.37", shownAtMillis = NOW - DAY_MILLIS - 1)
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN optional shown for older version WHEN getCached THEN OptionalUpdate`() = runTest {
|
||||||
|
givenCached(appVersion = "5.0", info = info(latestVersion = "5.37"))
|
||||||
|
coEvery { repository.getOptionalUpdateShown() } returns
|
||||||
|
OptionalUpdateShown(version = "5.36", shownAtMillis = NOW)
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.OptionalUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN all thresholds null WHEN getCached THEN NoUpdate`() = runTest {
|
||||||
|
givenCached(info = info())
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN critical and latest both match WHEN getCached THEN critical wins`() = runTest {
|
||||||
|
givenCached(
|
||||||
|
appVersion = "3.0",
|
||||||
|
osVersion = "14",
|
||||||
|
info = info(criticalVersion = "3.0", minSupportedVersion = "3.0", latestVersion = "5.37"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.ForceUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN no cache WHEN getCached THEN NoUpdate`() = runTest {
|
||||||
|
givenCached(info = null)
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN cache older than TTL WHEN getCached THEN NoUpdate`() = runTest {
|
||||||
|
givenCached(appVersion = "5.0", info = info(criticalVersion = "5.0", latestVersion = "5.1"))
|
||||||
|
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW - DAY_MILLIS - 1
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN cache exactly at TTL WHEN getCached THEN still blocks`() = runTest {
|
||||||
|
givenCached(appVersion = "5.0", info = info(criticalVersion = "5.0", latestVersion = "5.1"))
|
||||||
|
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW - DAY_MILLIS
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.ForceUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN cache without timestamp WHEN getCached THEN NoUpdate`() = runTest {
|
||||||
|
givenCached(appVersion = "5.0", info = info(criticalVersion = "5.0", latestVersion = "5.1"))
|
||||||
|
coEvery { repository.getCachedAppVersionTimestamp() } returns null
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN refresh fails and cache stale WHEN refresh THEN NoUpdate`() = runTest {
|
||||||
|
every { appInfoProvider.appVersion } returns "5.0"
|
||||||
|
every { appInfoProvider.osVersion } returns "14"
|
||||||
|
coEvery { repository.refreshAppVersionInfo() } returns IllegalStateException("error").left()
|
||||||
|
coEvery { repository.getCachedAppVersionInfo() } returns info(criticalVersion = "5.0", latestVersion = "5.1")
|
||||||
|
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW - DAY_MILLIS - 1
|
||||||
|
|
||||||
|
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.NoUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN refresh returns blocking info WHEN refresh THEN ForceUpdate`() = runTest {
|
||||||
|
givenRefresh(appVersion = "5.0", osVersion = "14", info = info(criticalVersion = "5.0", latestVersion = "5.1"))
|
||||||
|
|
||||||
|
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.ForceUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN refresh returns optional info WHEN refresh THEN OptionalUpdate without recording`() = runTest {
|
||||||
|
givenRefresh(appVersion = "5.0", info = info(latestVersion = "5.37"))
|
||||||
|
|
||||||
|
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.OptionalUpdate)
|
||||||
|
coVerify(exactly = 0) { repository.setOptionalUpdateShown(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN refresh fails WHEN refresh THEN falls back to cached thresholds`() = runTest {
|
||||||
|
every { appInfoProvider.appVersion } returns "5.0"
|
||||||
|
every { appInfoProvider.osVersion } returns "14"
|
||||||
|
coEvery { repository.refreshAppVersionInfo() } returns IllegalStateException("error").left()
|
||||||
|
coEvery { repository.getCachedAppVersionInfo() } returns info(criticalVersion = "5.0", latestVersion = "5.1")
|
||||||
|
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW
|
||||||
|
|
||||||
|
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.ForceUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN repository throws WHEN getCached THEN NoUpdate`() = runTest {
|
||||||
|
coEvery { repository.getCachedAppVersionTimestamp() } returns NOW
|
||||||
|
coEvery { repository.getCachedAppVersionInfo() } throws IllegalStateException("boom")
|
||||||
|
|
||||||
|
assertThat(useCase.getCached()).isEqualTo(AppUpdateState.NoUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN repository throws WHEN refresh THEN NoUpdate`() = runTest {
|
||||||
|
coEvery { repository.refreshAppVersionInfo() } throws IllegalStateException("boom")
|
||||||
|
|
||||||
|
assertThat(useCase.refresh()).isEqualTo(AppUpdateState.NoUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val NOW = 1_000_000_000_000L
|
||||||
|
const val DAY_MILLIS = 24L * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
package com.tangem.domain.appupdate.model
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
internal class AppVersionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN single segment WHEN parse THEN parsed`() {
|
||||||
|
assertThat(AppVersion.parseOrNull("14")).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN major and minor WHEN parse THEN parsed`() {
|
||||||
|
assertThat(AppVersion.parseOrNull("5.30")).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN major minor fix WHEN parse THEN parsed`() {
|
||||||
|
assertThat(AppVersion.parseOrNull("5.40.1")).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN empty minor part WHEN parse THEN null`() {
|
||||||
|
assertThat(AppVersion.parseOrNull("5..1")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN non-numeric part WHEN parse THEN null`() {
|
||||||
|
assertThat(AppVersion.parseOrNull("5.x")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN blank WHEN parse THEN null`() {
|
||||||
|
assertThat(AppVersion.parseOrNull("")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN trailing dot WHEN parse THEN null`() {
|
||||||
|
assertThat(AppVersion.parseOrNull("5.")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN older version WHEN compare THEN less than newer`() {
|
||||||
|
assertThat(AppVersion.parseOrNull("14")!! < AppVersion.parseOrNull("15.0")!!).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN fix difference WHEN compare THEN ordered`() {
|
||||||
|
assertThat(AppVersion.parseOrNull("5.40.1")!! > AppVersion.parseOrNull("5.40.0")!!).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN missing minor WHEN compare to explicit zero THEN equal`() {
|
||||||
|
val implicit = AppVersion.parseOrNull("14")!!
|
||||||
|
val explicit = AppVersion.parseOrNull("14.0")!!
|
||||||
|
|
||||||
|
assertThat(implicit.compareTo(explicit)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN build-type suffix WHEN parse THEN parsed without suffix`() {
|
||||||
|
assertThat(AppVersion.parseOrNull("6.1-internal")).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN snapshot fallback WHEN parse THEN parsed`() {
|
||||||
|
assertThat(AppVersion.parseOrNull("1.0.0-SNAPSHOT")).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN suffixed version WHEN compare to clean THEN equal`() {
|
||||||
|
val suffixed = AppVersion.parseOrNull("6.1-internal")!!
|
||||||
|
val clean = AppVersion.parseOrNull("6.1")!!
|
||||||
|
|
||||||
|
assertThat(suffixed.compareTo(clean)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
features/force-update/api/.gitignore
vendored
Normal file
1
features/force-update/api/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
19
features/force-update/api/build.gradle.kts
Normal file
19
features/force-update/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
plugins {
|
||||||
|
alias(deps.plugins.android.library)
|
||||||
|
alias(deps.plugins.kotlin.android)
|
||||||
|
id("configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.tangem.features.forceupdate.api"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
/* Project - Core */
|
||||||
|
implementation(projects.core.decompose)
|
||||||
|
implementation(projects.core.ui)
|
||||||
|
|
||||||
|
/* Compose */
|
||||||
|
implementation(deps.compose.runtime)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.tangem.features.forceupdate
|
||||||
|
|
||||||
|
import com.tangem.core.decompose.factory.ComponentFactory
|
||||||
|
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||||
|
|
||||||
|
interface ForceUpdateComponent : ComposableContentComponent {
|
||||||
|
|
||||||
|
interface Factory : ComponentFactory<Params, ForceUpdateComponent>
|
||||||
|
|
||||||
|
data class Params(val mode: Mode)
|
||||||
|
|
||||||
|
enum class Mode { Force, Brick, OsTooOld, Optional }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.tangem.features.forceupdate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumes the regular app startup after an optional update screen is dismissed.
|
||||||
|
*
|
||||||
|
* The startup awaits [awaitDismiss] while the optional update is shown, and the screen calls
|
||||||
|
* [dismiss] on "Later" to let the normal startup (and its side effects) run afterwards.
|
||||||
|
*/
|
||||||
|
interface ForceUpdateContinuation {
|
||||||
|
|
||||||
|
suspend fun awaitDismiss()
|
||||||
|
|
||||||
|
fun dismiss()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.tangem.features.forceupdate
|
||||||
|
|
||||||
|
interface ForceUpdateFeatureToggles {
|
||||||
|
|
||||||
|
val isForceUpdateEnabled: Boolean
|
||||||
|
}
|
||||||
1
features/force-update/impl/.gitignore
vendored
Normal file
1
features/force-update/impl/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
40
features/force-update/impl/build.gradle.kts
Normal file
40
features/force-update/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
plugins {
|
||||||
|
alias(deps.plugins.android.library)
|
||||||
|
alias(deps.plugins.kotlin.android)
|
||||||
|
alias(deps.plugins.kotlin.kapt)
|
||||||
|
alias(deps.plugins.hilt.android)
|
||||||
|
id("configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.tangem.features.forceupdate.impl"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
/* AndroidX */
|
||||||
|
implementation(deps.lifecycle.compose)
|
||||||
|
implementation(deps.androidx.activity.compose)
|
||||||
|
|
||||||
|
/** Compose */
|
||||||
|
implementation(deps.compose.foundation)
|
||||||
|
implementation(deps.compose.ui)
|
||||||
|
implementation(deps.compose.ui.tooling)
|
||||||
|
implementation(deps.compose.material3)
|
||||||
|
|
||||||
|
/** Core modules */
|
||||||
|
implementation(projects.core.ui)
|
||||||
|
implementation(projects.core.utils)
|
||||||
|
implementation(projects.core.configToggles)
|
||||||
|
implementation(projects.core.navigation)
|
||||||
|
implementation(projects.core.decompose)
|
||||||
|
|
||||||
|
/** Feature modules */
|
||||||
|
implementation(projects.features.forceUpdate.api)
|
||||||
|
|
||||||
|
/** Domain modules */
|
||||||
|
implementation(projects.domain.appUpdate)
|
||||||
|
|
||||||
|
/** DI */
|
||||||
|
implementation(deps.hilt.android)
|
||||||
|
kapt(deps.hilt.kapt)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.tangem.features.forceupdate.impl
|
||||||
|
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateContinuation
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
internal class DefaultForceUpdateContinuation @Inject constructor() : ForceUpdateContinuation {
|
||||||
|
|
||||||
|
private val dismissals = Channel<Unit>(capacity = Channel.CONFLATED)
|
||||||
|
|
||||||
|
override suspend fun awaitDismiss() {
|
||||||
|
dismissals.receive()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dismiss() {
|
||||||
|
dismissals.trySend(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.tangem.features.forceupdate.impl
|
||||||
|
|
||||||
|
import com.tangem.core.configtoggle.FeatureToggles
|
||||||
|
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateFeatureToggles
|
||||||
|
|
||||||
|
internal class DefaultForceUpdateFeatureToggles(
|
||||||
|
private val featureTogglesManager: FeatureTogglesManager,
|
||||||
|
) : ForceUpdateFeatureToggles {
|
||||||
|
|
||||||
|
override val isForceUpdateEnabled: Boolean
|
||||||
|
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1322_FORCE_UPDATE_ENABLED)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package com.tangem.features.forceupdate.impl.component
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.tangem.core.decompose.context.AppComponentContext
|
||||||
|
import com.tangem.core.decompose.model.getOrCreateModel
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateComponent
|
||||||
|
import com.tangem.features.forceupdate.impl.model.ForceUpdateModel
|
||||||
|
import com.tangem.features.forceupdate.impl.ui.ForceUpdateContent
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
|
||||||
|
internal class DefaultForceUpdateComponent @AssistedInject constructor(
|
||||||
|
@Assisted context: AppComponentContext,
|
||||||
|
@Assisted params: ForceUpdateComponent.Params,
|
||||||
|
) : ForceUpdateComponent, AppComponentContext by context {
|
||||||
|
|
||||||
|
private val model: ForceUpdateModel = getOrCreateModel(params)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content(modifier: Modifier) {
|
||||||
|
val state by model.state.collectAsStateWithLifecycle()
|
||||||
|
ForceUpdateContent(state = state, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory : ForceUpdateComponent.Factory {
|
||||||
|
|
||||||
|
override fun create(
|
||||||
|
context: AppComponentContext,
|
||||||
|
params: ForceUpdateComponent.Params,
|
||||||
|
): DefaultForceUpdateComponent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.tangem.features.forceupdate.impl.di
|
||||||
|
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateComponent
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateContinuation
|
||||||
|
import com.tangem.features.forceupdate.impl.DefaultForceUpdateContinuation
|
||||||
|
import com.tangem.features.forceupdate.impl.component.DefaultForceUpdateComponent
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
internal interface ComponentModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
fun bindForceUpdateComponentFactory(factory: DefaultForceUpdateComponent.Factory): ForceUpdateComponent.Factory
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
fun bindForceUpdateContinuation(impl: DefaultForceUpdateContinuation): ForceUpdateContinuation
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.tangem.features.forceupdate.impl.di
|
||||||
|
|
||||||
|
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateFeatureToggles
|
||||||
|
import com.tangem.features.forceupdate.impl.DefaultForceUpdateFeatureToggles
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
internal object ForceUpdateFeatureModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideForceUpdateFeatureToggles(featureTogglesManager: FeatureTogglesManager): ForceUpdateFeatureToggles =
|
||||||
|
DefaultForceUpdateFeatureToggles(featureTogglesManager)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.tangem.features.forceupdate.impl.di
|
||||||
|
|
||||||
|
import com.tangem.core.decompose.di.ModelComponent
|
||||||
|
import com.tangem.core.decompose.model.Model
|
||||||
|
import com.tangem.features.forceupdate.impl.model.ForceUpdateModel
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.multibindings.ClassKey
|
||||||
|
import dagger.multibindings.IntoMap
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(ModelComponent::class)
|
||||||
|
internal interface ModelModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@IntoMap
|
||||||
|
@ClassKey(ForceUpdateModel::class)
|
||||||
|
fun provideForceUpdateModel(model: ForceUpdateModel): Model
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
package com.tangem.features.forceupdate.impl.model
|
||||||
|
|
||||||
|
import com.tangem.core.decompose.di.ModelScoped
|
||||||
|
import com.tangem.core.decompose.model.Model
|
||||||
|
import com.tangem.core.decompose.model.ParamsContainer
|
||||||
|
import com.tangem.core.navigation.url.AppStoreOpener
|
||||||
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
|
import com.tangem.domain.appupdate.model.AppUpdateState
|
||||||
|
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateComponent
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateContinuation
|
||||||
|
import com.tangem.features.forceupdate.impl.R
|
||||||
|
import com.tangem.features.forceupdate.impl.ui.state.ForceUpdateUM
|
||||||
|
import com.tangem.features.forceupdate.impl.ui.state.ForceUpdateUM.Accent
|
||||||
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ModelScoped
|
||||||
|
internal class ForceUpdateModel @Inject constructor(
|
||||||
|
override val dispatchers: CoroutineDispatcherProvider,
|
||||||
|
private val appStoreOpener: AppStoreOpener,
|
||||||
|
private val forceUpdateContinuation: ForceUpdateContinuation,
|
||||||
|
private val getAppUpdateStateUseCase: GetAppUpdateStateUseCase,
|
||||||
|
paramsContainer: ParamsContainer,
|
||||||
|
) : Model() {
|
||||||
|
|
||||||
|
private val params: ForceUpdateComponent.Params = paramsContainer.require()
|
||||||
|
|
||||||
|
val state: StateFlow<ForceUpdateUM>
|
||||||
|
field = MutableStateFlow(createState(params.mode))
|
||||||
|
|
||||||
|
init {
|
||||||
|
checkAppUpdateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createState(mode: ForceUpdateComponent.Mode): ForceUpdateUM = when (mode) {
|
||||||
|
ForceUpdateComponent.Mode.Force -> ForceUpdateUM(
|
||||||
|
mode = mode,
|
||||||
|
accent = Accent.Red,
|
||||||
|
title = resourceReference(R.string.force_update_warning_title),
|
||||||
|
description = resourceReference(R.string.force_update_warning_message),
|
||||||
|
isBlocking = true,
|
||||||
|
onUpdateClick = ::onUpdateClick,
|
||||||
|
onLaterClick = null,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
mode = mode,
|
||||||
|
accent = Accent.Red,
|
||||||
|
title = resourceReference(R.string.force_update_brick_title),
|
||||||
|
description = resourceReference(R.string.force_update_brick_description),
|
||||||
|
isBlocking = true,
|
||||||
|
onUpdateClick = null,
|
||||||
|
onLaterClick = null,
|
||||||
|
)
|
||||||
|
ForceUpdateComponent.Mode.OsTooOld -> ForceUpdateUM(
|
||||||
|
mode = mode,
|
||||||
|
accent = Accent.Red,
|
||||||
|
title = resourceReference(R.string.force_update_os_title),
|
||||||
|
description = resourceReference(R.string.force_update_os_description),
|
||||||
|
isBlocking = true,
|
||||||
|
onUpdateClick = null,
|
||||||
|
onLaterClick = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onUpdateClick() {
|
||||||
|
appStoreOpener.openStorePage()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLaterClick() {
|
||||||
|
forceUpdateContinuation.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-checks the update state once when the screen opens. A concrete result refines the displayed mode
|
||||||
|
* (e.g. escalates an optional update to a blocking one). When the update is no longer required — or the
|
||||||
|
* state can't be resolved — the screen is dismissed so the regular startup can proceed. Network errors
|
||||||
|
* fall back to the cached thresholds, so a transient failure keeps the current screen.
|
||||||
|
*/
|
||||||
|
private fun checkAppUpdateState() {
|
||||||
|
modelScope.launch {
|
||||||
|
val mode = getAppUpdateStateUseCase.refresh().toModeOrNull()
|
||||||
|
if (mode == null) {
|
||||||
|
forceUpdateContinuation.dismiss()
|
||||||
|
} else {
|
||||||
|
state.update { current -> if (current.mode == mode) current else createState(mode) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AppUpdateState.toModeOrNull(): ForceUpdateComponent.Mode? = when (this) {
|
||||||
|
AppUpdateState.ForceUpdate -> ForceUpdateComponent.Mode.Force
|
||||||
|
AppUpdateState.Brick -> ForceUpdateComponent.Mode.Brick
|
||||||
|
AppUpdateState.OsTooOld -> ForceUpdateComponent.Mode.OsTooOld
|
||||||
|
AppUpdateState.OptionalUpdate -> ForceUpdateComponent.Mode.Optional
|
||||||
|
AppUpdateState.NoUpdate -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
package com.tangem.features.forceupdate.impl.ui
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tangem.core.ui.ds2.button.TangemButton
|
||||||
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
|
import com.tangem.core.ui.extensions.resolveReference
|
||||||
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
|
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||||
|
import com.tangem.core.ui.res.generated.icons.Icons
|
||||||
|
import com.tangem.core.ui.res.generated.icons.ic_error_24
|
||||||
|
import com.tangem.core.ui.res.generated.icons.ic_warning_24
|
||||||
|
import com.tangem.features.forceupdate.impl.R
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateComponent
|
||||||
|
import com.tangem.features.forceupdate.impl.ui.state.ForceUpdateUM
|
||||||
|
import com.tangem.features.forceupdate.impl.ui.state.ForceUpdateUM.Accent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ForceUpdateContent(state: ForceUpdateUM, modifier: Modifier = Modifier) {
|
||||||
|
BackHandler(enabled = !state.isBlocking) {
|
||||||
|
state.onLaterClick?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
val accentColor = when (state.accent) {
|
||||||
|
Accent.Red -> TangemTheme.colors3.icon.accent.red
|
||||||
|
Accent.Yellow -> TangemTheme.colors3.icon.accent.yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(TangemTheme.colors3.bg.primary)
|
||||||
|
.glow(accentColor),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.systemBarsPadding()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Content(
|
||||||
|
state = state,
|
||||||
|
accentColor = accentColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Buttons(
|
||||||
|
state = state,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Content(state: ForceUpdateUM, accentColor: Color, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Spacer(modifier = Modifier.height(64.dp))
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
imageVector = when (state.accent) {
|
||||||
|
Accent.Red -> Icons.ic_error_24
|
||||||
|
Accent.Yellow -> Icons.ic_warning_24
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
tint = accentColor,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
text = state.title.resolveReference(),
|
||||||
|
style = TangemTheme.typography3.heading.medium,
|
||||||
|
color = TangemTheme.colors3.text.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = state.description.resolveReference(),
|
||||||
|
style = TangemTheme.typography3.heading.medium,
|
||||||
|
color = TangemTheme.colors3.text.secondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Buttons(state: ForceUpdateUM, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
state.onUpdateClick?.let { onClick ->
|
||||||
|
TangemButton(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
variant = TangemButton.Variant.Primary,
|
||||||
|
text = resourceReference(R.string.force_update_action),
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.onLaterClick?.let { onClick ->
|
||||||
|
TangemButton(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
variant = TangemButton.Variant.Secondary,
|
||||||
|
text = resourceReference(R.string.common_later),
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Modifier.glow(color: Color): Modifier = drawBehind {
|
||||||
|
drawRect(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(color.copy(alpha = GLOW_ALPHA), Color.Transparent),
|
||||||
|
center = Offset(x = size.width * GLOW_CENTER_X_FRACTION, y = 0f),
|
||||||
|
radius = size.width,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val GLOW_ALPHA = 0.32f
|
||||||
|
private const val GLOW_CENTER_X_FRACTION = 0.3f
|
||||||
|
|
||||||
|
@Preview(showBackground = true, widthDp = 360, heightDp = 780)
|
||||||
|
@Preview(showBackground = true, widthDp = 360, heightDp = 780, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewForce() {
|
||||||
|
TangemThemePreviewRedesign {
|
||||||
|
ForceUpdateContent(
|
||||||
|
state = ForceUpdateUM(
|
||||||
|
mode = ForceUpdateComponent.Mode.Force,
|
||||||
|
accent = Accent.Red,
|
||||||
|
title = TextReference.Str("Update Required"),
|
||||||
|
description = TextReference.Str("Please update the application to the latest version."),
|
||||||
|
isBlocking = true,
|
||||||
|
onUpdateClick = {},
|
||||||
|
onLaterClick = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, widthDp = 360, heightDp = 780)
|
||||||
|
@Preview(showBackground = true, widthDp = 360, heightDp = 780, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewOptional() {
|
||||||
|
TangemThemePreviewRedesign {
|
||||||
|
ForceUpdateContent(
|
||||||
|
state = ForceUpdateUM(
|
||||||
|
mode = ForceUpdateComponent.Mode.Optional,
|
||||||
|
accent = Accent.Yellow,
|
||||||
|
title = TextReference.Str("Update Required"),
|
||||||
|
description = TextReference.Str("Please update the application to the latest version."),
|
||||||
|
isBlocking = false,
|
||||||
|
onUpdateClick = {},
|
||||||
|
onLaterClick = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.tangem.features.forceupdate.impl.ui.state
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
|
import com.tangem.features.forceupdate.ForceUpdateComponent
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
internal data class ForceUpdateUM(
|
||||||
|
val mode: ForceUpdateComponent.Mode,
|
||||||
|
val accent: Accent,
|
||||||
|
val title: TextReference,
|
||||||
|
val description: TextReference,
|
||||||
|
val isBlocking: Boolean,
|
||||||
|
val onUpdateClick: (() -> Unit)?,
|
||||||
|
val onLaterClick: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
|
||||||
|
enum class Accent { Red, Yellow }
|
||||||
|
}
|
||||||
|
|
@ -290,6 +290,9 @@ include(":features:details:impl")
|
||||||
include(":features:disclaimer:api")
|
include(":features:disclaimer:api")
|
||||||
include(":features:disclaimer:impl")
|
include(":features:disclaimer:impl")
|
||||||
|
|
||||||
|
include(":features:force-update:api")
|
||||||
|
include(":features:force-update:impl")
|
||||||
|
|
||||||
include(":features:usedesk:api")
|
include(":features:usedesk:api")
|
||||||
include(":features:usedesk:impl")
|
include(":features:usedesk:impl")
|
||||||
|
|
||||||
|
|
@ -401,6 +404,7 @@ include(":domain:legacy")
|
||||||
include(":domain:account")
|
include(":domain:account")
|
||||||
include(":domain:account:status")
|
include(":domain:account:status")
|
||||||
include(":domain:address-book")
|
include(":domain:address-book")
|
||||||
|
include(":domain:app-update")
|
||||||
include(":domain:card")
|
include(":domain:card")
|
||||||
include(":domain:common")
|
include(":domain:common")
|
||||||
include(":domain:core")
|
include(":domain:core")
|
||||||
|
|
@ -478,6 +482,7 @@ include(":domain:search")
|
||||||
include(":data:account")
|
include(":data:account")
|
||||||
include(":data:address-book")
|
include(":data:address-book")
|
||||||
include(":data:app-currency")
|
include(":data:app-currency")
|
||||||
|
include(":data:app-update")
|
||||||
include(":data:app-theme")
|
include(":data:app-theme")
|
||||||
include(":data:balance-hiding")
|
include(":data:balance-hiding")
|
||||||
include(":data:push-notification-preferences")
|
include(":data:push-notification-preferences")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue