diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2492476d2f..7782a2cf4a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -124,6 +124,7 @@ dependencies { implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) implementation(projects.domain.settings) + implementation(projects.domain.appUpdate) implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) implementation(projects.domain.txhistory) @@ -199,6 +200,7 @@ dependencies { implementation(projects.data.card) implementation(projects.data.common) implementation(projects.data.settings) + implementation(projects.data.appUpdate) implementation(projects.data.tokens) implementation(projects.data.assetsdiscovery) implementation(projects.data.txhistory) @@ -264,6 +266,8 @@ dependencies { implementation(projects.features.details.impl) implementation(projects.features.disclaimer.api) implementation(projects.features.disclaimer.impl) + implementation(projects.features.forceUpdate.api) + implementation(projects.features.forceUpdate.impl) implementation(projects.features.pushNotifications.api) implementation(projects.features.pushNotifications.impl) implementation(projects.features.pushNotificationSettings.api) diff --git a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt index d8cb34aece..b94c16928d 100644 --- a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt @@ -7,6 +7,8 @@ import com.tangem.core.navigation.deeplink.DeeplinkLauncher import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.core.navigation.settings.SettingsManager 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.utils.coroutines.AppCoroutineScope import com.tangem.tap.common.finisher.AndroidAppFinisher @@ -35,6 +37,10 @@ internal interface UtilsModule { @Singleton fun bindAppInfoProvider(impl: DefaultAppInfoProvider): AppInfoProvider + @Binds + @Singleton + fun bindAppStoreOpener(impl: DefaultAppStoreOpener): AppStoreOpener + companion object { @Provides diff --git a/app/src/main/java/com/tangem/tap/features/root/DefaultRootWarningContinuation.kt b/app/src/main/java/com/tangem/tap/features/root/DefaultRootWarningContinuation.kt new file mode 100644 index 0000000000..972c40e0ef --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/root/DefaultRootWarningContinuation.kt @@ -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(capacity = Channel.CONFLATED) + + override suspend fun awaitDismiss() { + dismissals.receive() + } + + override fun dismiss() { + dismissals.trySend(Unit) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/root/RootDetectedWarningComponent.kt b/app/src/main/java/com/tangem/tap/features/root/RootDetectedWarningComponent.kt index 8a28992da8..e65b05a500 100644 --- a/app/src/main/java/com/tangem/tap/features/root/RootDetectedWarningComponent.kt +++ b/app/src/main/java/com/tangem/tap/features/root/RootDetectedWarningComponent.kt @@ -1,68 +1,40 @@ package com.tangem.tap.features.root import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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.factory.ComponentFactory import com.tangem.core.ui.components.DialogFullScreen 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.AssistedFactory 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") -class RootDetectedWarningComponent @AssistedInject constructor( +internal class RootDetectedWarningComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted params: Unit, - private val securityInfoProvider: DeviceSecurityInfoProvider, - private val settingsRepository: SettingsRepository, + private val rootWarningContinuation: RootWarningContinuation, ) : 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 override fun Content(modifier: Modifier) { - val isShownState by isShown.collectAsStateWithLifecycle() - - if (isShownState) { - DialogFullScreen(onDismissRequest = {}) { - RootDetectedWarningContent( - modifier = modifier, - onContinueClick = remember(this) { ::onContinueClick }, - ) - } + DialogFullScreen(onDismissRequest = {}) { + RootDetectedWarningContent( + modifier = modifier, + onContinueClick = remember(this) { ::onContinueClick }, + ) } } private fun onContinueClick() { - componentScope.launch { - settingsRepository.setRootDetectedWarningShown(true) - isShown.value = false - } + rootWarningContinuation.dismiss() } @AssistedFactory diff --git a/app/src/main/java/com/tangem/tap/features/root/RootWarningContinuation.kt b/app/src/main/java/com/tangem/tap/features/root/RootWarningContinuation.kt new file mode 100644 index 0000000000..37f7f5bfdd --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/root/RootWarningContinuation.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/root/di/RootModule.kt b/app/src/main/java/com/tangem/tap/features/root/di/RootModule.kt new file mode 100644 index 0000000000..375b112cfd --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/root/di/RootModule.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/RootContent.kt b/app/src/main/java/com/tangem/tap/routing/RootContent.kt index 762f6b21a6..279a41730a 100644 --- a/app/src/main/java/com/tangem/tap/routing/RootContent.kt +++ b/app/src/main/java/com/tangem/tap/routing/RootContent.kt @@ -43,7 +43,7 @@ internal fun RootContent( modifier: Modifier = Modifier, wcContent: @Composable (modifier: Modifier) -> Unit, hotAccessCodeContent: @Composable (modifier: Modifier) -> Unit, - rootDetectedWarningContent: @Composable (modifier: Modifier) -> Unit, + startupGateContent: @Composable (modifier: Modifier) -> Unit, scanFailsContent: @Composable (modifier: Modifier) -> Unit, ) { val context = LocalContext.current @@ -82,7 +82,7 @@ internal fun RootContent( hotAccessCodeContent(Modifier.fillMaxSize()) - rootDetectedWarningContent(Modifier.fillMaxSize()) + startupGateContent(Modifier.fillMaxSize()) scanFailsContent(Modifier.fillMaxSize()) diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index 6e5488d67b..7fd765899e 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -55,13 +55,13 @@ import com.tangem.sdk.api.BackupServiceHolder import com.tangem.tap.common.SnackbarHandler import com.tangem.tap.common.analytics.appsflyer.AppsFlyerReferralParamsHandler 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.ScanFailsRequesterProxy import com.tangem.tap.routing.RootContent import com.tangem.tap.routing.component.RoutingComponent import com.tangem.tap.routing.component.RoutingComponent.Child 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.DeepLinkFactory import com.tangem.utils.logging.TangemLogger @@ -86,7 +86,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor( private val tangemHotSDKProxy: TangemHotSDKProxy, private val hotAccessCodeRequestComponentFactory: HotAccessCodeRequestComponent.Factory, private val hotAccessCodeRequesterProxy: HotWalletPasswordRequesterProxy, - private val rootDetectedWarningComponentFactory: RootDetectedWarningComponent.Factory, + private val appStartupGateComponentFactory: AppStartupGateComponent.Factory, private val userWalletsListRepository: UserWalletsListRepository, private val cardRepository: CardRepository, private val onboardingRepository: OnboardingRepository, @@ -117,9 +117,8 @@ internal class DefaultRoutingComponent @AssistedInject constructor( .create(child("hotAccessCodeRequestComponent"), Unit) } - private val rootDetectedWarningComponent: RootDetectedWarningComponent by lazy { - rootDetectedWarningComponentFactory - .create(child("rootDetectedWarningComponent"), Unit) + private val appStartupGateComponent: AppStartupGateComponent by lazy { + appStartupGateComponentFactory.create(child("appStartupGate")) } private val scanFailsComponent: ScanFailsComponent by lazy { @@ -175,40 +174,37 @@ internal class DefaultRoutingComponent @AssistedInject constructor( private fun initializeInitialNavigation() { if (initialStack.isNullOrEmpty()) { - componentScope.launch { - val initialRoute = resolveInitialRoute() - if (rootDetectedWarningComponent.shouldShowWarning()) { - launch(dispatchers.main) { - rootDetectedWarningComponent.tryToShowWarningAndWaitContinuation() - router.replaceAll(initialRoute) - } - } else { - router.replaceAll(initialRoute) - } - } + componentScope.launch { resolveAndNavigate() } } } - 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() return when { userWallets.isEmpty() -> navigateForEmptyWallets() - userWallets.any { it.isLocked } -> { - AppRoute.Welcome( - launchMode = launchMode, - ) - } - else -> { - trackSignInEvent() - AppRoute.Wallet - } - }.also { - appRouterConfig.initializedState.value = true - checkForUnfinishedBackup() + userWallets.any { it.isLocked } -> AppRoute.Welcome(launchMode = launchMode) + else -> AppRoute.Wallet } } + private suspend fun onInitialRouteResolved(route: AppRoute) { + appRouterConfig.initializedState.value = true + if (route is AppRoute.Wallet) trackSignInEvent() + checkForUnfinishedBackup() + } + private suspend fun navigateForEmptyWallets(): AppRoute { val isHotWalletOnboardingEnabled = featureTogglesManager.isFeatureEnabled( FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING, @@ -269,7 +265,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor( modifier = modifier, wcContent = { wcRoutingComponent.Content(it) }, hotAccessCodeContent = { hotAccessCodeRequestComponent.Content(it) }, - rootDetectedWarningContent = { rootDetectedWarningComponent.Content(it) }, + startupGateContent = { appStartupGateComponent.Content(it) }, scanFailsContent = { scanFailsComponent.Content(it) }, ) } diff --git a/app/src/main/java/com/tangem/tap/routing/startup/AppStartupGateComponent.kt b/app/src/main/java/com/tangem/tap/routing/startup/AppStartupGateComponent.kt new file mode 100644 index 0000000000..cd2911409e --- /dev/null +++ b/app/src/main/java/com/tangem/tap/routing/startup/AppStartupGateComponent.kt @@ -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() + + private val slot: Value> = 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 + } +} \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 51f10c5b8c..581ed0dd70 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -3,6 +3,10 @@ "name": "AND_15901_STORIES_CONTAINER_ENABLED", "version": "undefined" }, + { + "name": "TWI_1322_FORCE_UPDATE_ENABLED", + "version": "undefined" + }, { "name": "NEW_CARD_SCANNING_ENABLED", "version": "undefined" diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index 244998c682..1f4cac6ed3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -46,6 +46,9 @@ interface TangemTechApi { @GET("v1/geo") suspend fun getUserCountryCode(): GeoResponse + @GET("v1/application/versions") + suspend fun getApplicationVersions(): ApiResponse + @PUT("/v1/wallets/{walletId}/tokens") suspend fun saveTokens( @Path(value = "walletId") userId: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/ApplicationVersionsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/ApplicationVersionsResponse.kt new file mode 100644 index 0000000000..7e65c48d2d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/ApplicationVersionsResponse.kt @@ -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?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 84dc5ebd31..2ad7db9881 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -33,6 +33,16 @@ object PreferencesKeys { 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 FUNDS_FOUND_DATE_KEY by lazy { longPreferencesKey(name = "fundsFoundDate") } diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 4895789589..a2e1dbdd1e 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -10,6 +10,10 @@ android { dependencies { + // region Core modules + implementation(projects.core.utils) + // endregion + // region DI implementation(deps.hilt.android) // endregion diff --git a/core/navigation/src/main/java/com/tangem/core/navigation/url/AppStoreOpener.kt b/core/navigation/src/main/java/com/tangem/core/navigation/url/AppStoreOpener.kt new file mode 100644 index 0000000000..4e1f88f6b0 --- /dev/null +++ b/core/navigation/src/main/java/com/tangem/core/navigation/url/AppStoreOpener.kt @@ -0,0 +1,6 @@ +package com.tangem.core.navigation.url + +interface AppStoreOpener { + + fun openStorePage() +} \ No newline at end of file diff --git a/core/navigation/src/main/java/com/tangem/core/navigation/url/DefaultAppStoreOpener.kt b/core/navigation/src/main/java/com/tangem/core/navigation/url/DefaultAppStoreOpener.kt new file mode 100644 index 0000000000..8aea79f0d1 --- /dev/null +++ b/core/navigation/src/main/java/com/tangem/core/navigation/url/DefaultAppStoreOpener.kt @@ -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/" + } +} \ No newline at end of file diff --git a/data/app-update/.gitignore b/data/app-update/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/data/app-update/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/app-update/build.gradle.kts b/data/app-update/build.gradle.kts new file mode 100644 index 0000000000..aa1465aa42 --- /dev/null +++ b/data/app-update/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/data/app-update/src/main/java/com/tangem/data/appupdate/DefaultAppUpdateRepository.kt b/data/app-update/src/main/java/com/tangem/data/appupdate/DefaultAppUpdateRepository.kt new file mode 100644 index 0000000000..f995321292 --- /dev/null +++ b/data/app-update/src/main/java/com/tangem/data/appupdate/DefaultAppUpdateRepository.kt @@ -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(PreferencesKeys.CACHED_APP_VERSIONS_KEY) + ?.toDomain() + + override suspend fun getCachedAppVersionTimestamp(): Long? = + appPreferencesStore.getSyncOrNull(PreferencesKeys.CACHED_APP_VERSIONS_AT_KEY) + + override suspend fun refreshAppVersionInfo(): Either = 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, + ) +} \ No newline at end of file diff --git a/data/app-update/src/main/java/com/tangem/data/appupdate/di/AppUpdateDataModule.kt b/data/app-update/src/main/java/com/tangem/data/appupdate/di/AppUpdateDataModule.kt new file mode 100644 index 0000000000..968f9f3e78 --- /dev/null +++ b/data/app-update/src/main/java/com/tangem/data/appupdate/di/AppUpdateDataModule.kt @@ -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, + ) +} \ No newline at end of file diff --git a/domain/app-update/.gitignore b/domain/app-update/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/app-update/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/app-update/build.gradle.kts b/domain/app-update/build.gradle.kts new file mode 100644 index 0000000000..a776200089 --- /dev/null +++ b/domain/app-update/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/model/AppUpdateState.kt b/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/model/AppUpdateState.kt new file mode 100644 index 0000000000..28d436f543 --- /dev/null +++ b/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/model/AppUpdateState.kt @@ -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, +} \ No newline at end of file diff --git a/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/model/AppVersion.kt b/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/model/AppVersion.kt new file mode 100644 index 0000000000..548548b004 --- /dev/null +++ b/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/model/AppVersion.kt @@ -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 { + + 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() + } + } +} \ No newline at end of file diff --git a/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/model/AppVersionInfo.kt b/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/model/AppVersionInfo.kt new file mode 100644 index 0000000000..0d9634fade --- /dev/null +++ b/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/model/AppVersionInfo.kt @@ -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?, +) \ No newline at end of file diff --git a/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/model/OptionalUpdateShown.kt b/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/model/OptionalUpdateShown.kt new file mode 100644 index 0000000000..e84909686c --- /dev/null +++ b/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/model/OptionalUpdateShown.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.appupdate.model + +data class OptionalUpdateShown( + val version: String, + val shownAtMillis: Long, +) \ No newline at end of file diff --git a/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/repository/AppUpdateRepository.kt b/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/repository/AppUpdateRepository.kt new file mode 100644 index 0000000000..30fd2a66ac --- /dev/null +++ b/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/repository/AppUpdateRepository.kt @@ -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 + + suspend fun getOptionalUpdateShown(): OptionalUpdateShown? + + suspend fun setOptionalUpdateShown(shown: OptionalUpdateShown) +} \ No newline at end of file diff --git a/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/usecase/GetAppUpdateStateUseCase.kt b/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/usecase/GetAppUpdateStateUseCase.kt new file mode 100644 index 0000000000..1a41a3e8a0 --- /dev/null +++ b/domain/app-update/src/main/kotlin/com/tangem/domain/appupdate/usecase/GetAppUpdateStateUseCase.kt @@ -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 + } +} \ No newline at end of file diff --git a/domain/app-update/src/test/kotlin/com/tangem/domain/appupdate/GetAppUpdateStateUseCaseTest.kt b/domain/app-update/src/test/kotlin/com/tangem/domain/appupdate/GetAppUpdateStateUseCaseTest.kt new file mode 100644 index 0000000000..dd415faf26 --- /dev/null +++ b/domain/app-update/src/test/kotlin/com/tangem/domain/appupdate/GetAppUpdateStateUseCaseTest.kt @@ -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() + private val appInfoProvider = mockk() + 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 + } +} \ No newline at end of file diff --git a/domain/app-update/src/test/kotlin/com/tangem/domain/appupdate/model/AppVersionTest.kt b/domain/app-update/src/test/kotlin/com/tangem/domain/appupdate/model/AppVersionTest.kt new file mode 100644 index 0000000000..d4cb4fee11 --- /dev/null +++ b/domain/app-update/src/test/kotlin/com/tangem/domain/appupdate/model/AppVersionTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/features/force-update/api/.gitignore b/features/force-update/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/force-update/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/force-update/api/build.gradle.kts b/features/force-update/api/build.gradle.kts new file mode 100644 index 0000000000..98659e8857 --- /dev/null +++ b/features/force-update/api/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/features/force-update/api/src/main/kotlin/com/tangem/features/forceupdate/ForceUpdateComponent.kt b/features/force-update/api/src/main/kotlin/com/tangem/features/forceupdate/ForceUpdateComponent.kt new file mode 100644 index 0000000000..3eb6fcdd29 --- /dev/null +++ b/features/force-update/api/src/main/kotlin/com/tangem/features/forceupdate/ForceUpdateComponent.kt @@ -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 + + data class Params(val mode: Mode) + + enum class Mode { Force, Brick, OsTooOld, Optional } +} \ No newline at end of file diff --git a/features/force-update/api/src/main/kotlin/com/tangem/features/forceupdate/ForceUpdateContinuation.kt b/features/force-update/api/src/main/kotlin/com/tangem/features/forceupdate/ForceUpdateContinuation.kt new file mode 100644 index 0000000000..f389898966 --- /dev/null +++ b/features/force-update/api/src/main/kotlin/com/tangem/features/forceupdate/ForceUpdateContinuation.kt @@ -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() +} \ No newline at end of file diff --git a/features/force-update/api/src/main/kotlin/com/tangem/features/forceupdate/ForceUpdateFeatureToggles.kt b/features/force-update/api/src/main/kotlin/com/tangem/features/forceupdate/ForceUpdateFeatureToggles.kt new file mode 100644 index 0000000000..2ade1f64e9 --- /dev/null +++ b/features/force-update/api/src/main/kotlin/com/tangem/features/forceupdate/ForceUpdateFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.features.forceupdate + +interface ForceUpdateFeatureToggles { + + val isForceUpdateEnabled: Boolean +} \ No newline at end of file diff --git a/features/force-update/impl/.gitignore b/features/force-update/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/force-update/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/force-update/impl/build.gradle.kts b/features/force-update/impl/build.gradle.kts new file mode 100644 index 0000000000..83fb3ba0a7 --- /dev/null +++ b/features/force-update/impl/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/DefaultForceUpdateContinuation.kt b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/DefaultForceUpdateContinuation.kt new file mode 100644 index 0000000000..1849999ecb --- /dev/null +++ b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/DefaultForceUpdateContinuation.kt @@ -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(capacity = Channel.CONFLATED) + + override suspend fun awaitDismiss() { + dismissals.receive() + } + + override fun dismiss() { + dismissals.trySend(Unit) + } +} \ No newline at end of file diff --git a/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/DefaultForceUpdateFeatureToggles.kt b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/DefaultForceUpdateFeatureToggles.kt new file mode 100644 index 0000000000..e5710a7ce0 --- /dev/null +++ b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/DefaultForceUpdateFeatureToggles.kt @@ -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) +} \ No newline at end of file diff --git a/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/component/DefaultForceUpdateComponent.kt b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/component/DefaultForceUpdateComponent.kt new file mode 100644 index 0000000000..50d0804527 --- /dev/null +++ b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/component/DefaultForceUpdateComponent.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/di/ComponentModule.kt b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..73f7caf77c --- /dev/null +++ b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/di/ComponentModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/di/ForceUpdateFeatureModule.kt b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/di/ForceUpdateFeatureModule.kt new file mode 100644 index 0000000000..6eb715edc8 --- /dev/null +++ b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/di/ForceUpdateFeatureModule.kt @@ -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) +} \ No newline at end of file diff --git a/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/di/ModelModule.kt b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/di/ModelModule.kt new file mode 100644 index 0000000000..bc11a1eaa8 --- /dev/null +++ b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/di/ModelModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/model/ForceUpdateModel.kt b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/model/ForceUpdateModel.kt new file mode 100644 index 0000000000..387306f78b --- /dev/null +++ b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/model/ForceUpdateModel.kt @@ -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 + 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 + } +} \ No newline at end of file diff --git a/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/ui/ForceUpdateContent.kt b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/ui/ForceUpdateContent.kt new file mode 100644 index 0000000000..b2f371f681 --- /dev/null +++ b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/ui/ForceUpdateContent.kt @@ -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 = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/ui/state/ForceUpdateUM.kt b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/ui/state/ForceUpdateUM.kt new file mode 100644 index 0000000000..c187dc04ac --- /dev/null +++ b/features/force-update/impl/src/main/kotlin/com/tangem/features/forceupdate/impl/ui/state/ForceUpdateUM.kt @@ -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 } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 1d962c44f0..9c75f5d58a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -290,6 +290,9 @@ include(":features:details:impl") include(":features:disclaimer:api") include(":features:disclaimer:impl") +include(":features:force-update:api") +include(":features:force-update:impl") + include(":features:usedesk:api") include(":features:usedesk:impl") @@ -401,6 +404,7 @@ include(":domain:legacy") include(":domain:account") include(":domain:account:status") include(":domain:address-book") +include(":domain:app-update") include(":domain:card") include(":domain:common") include(":domain:core") @@ -478,6 +482,7 @@ include(":domain:search") include(":data:account") include(":data:address-book") include(":data:app-currency") +include(":data:app-update") include(":data:app-theme") include(":data:balance-hiding") include(":data:push-notification-preferences")