Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-02 19:19:33 +05:00
parent 26bba893ff
commit 51e650c360
47 changed files with 1628 additions and 73 deletions

View file

@ -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)

View file

@ -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

View file

@ -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)
}
}

View file

@ -1,54 +1,30 @@
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,
@ -56,13 +32,9 @@ class RootDetectedWarningComponent @AssistedInject constructor(
)
}
}
}
private fun onContinueClick() {
componentScope.launch {
settingsRepository.setRootDetectedWarningShown(true)
isShown.value = false
}
rootWarningContinuation.dismiss()
}
@AssistedFactory

View file

@ -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()
}

View file

@ -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
}

View file

@ -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())

View file

@ -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,39 +174,36 @@ 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,
)
userWallets.any { it.isLocked } -> AppRoute.Welcome(launchMode = launchMode)
else -> AppRoute.Wallet
}
else -> {
trackSignInEvent()
AppRoute.Wallet
}
}.also {
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(
@ -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) },
)
}

View file

@ -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
}
}

View file

@ -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"

View file

@ -46,6 +46,9 @@ interface TangemTechApi {
@GET("v1/geo")
suspend fun getUserCountryCode(): GeoResponse
@GET("v1/application/versions")
suspend fun getApplicationVersions(): ApiResponse<ApplicationVersionsResponse>
@PUT("/v1/wallets/{walletId}/tokens")
suspend fun saveTokens(
@Path(value = "walletId") userId: String,

View file

@ -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?,
)

View file

@ -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") }

View file

@ -10,6 +10,10 @@ android {
dependencies {
// region Core modules
implementation(projects.core.utils)
// endregion
// region DI
implementation(deps.hilt.android)
// endregion

View file

@ -0,0 +1,6 @@
package com.tangem.core.navigation.url
interface AppStoreOpener {
fun openStorePage()
}

View file

@ -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
View file

@ -0,0 +1 @@
/build

View 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
}

View file

@ -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,
)
}

View file

@ -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
View file

@ -0,0 +1 @@
/build

View 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
}

View file

@ -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,
}

View file

@ -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()
}
}
}

View file

@ -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?,
)

View file

@ -0,0 +1,6 @@
package com.tangem.domain.appupdate.model
data class OptionalUpdateShown(
val version: String,
val shownAtMillis: Long,
)

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
View file

@ -0,0 +1 @@
/build

View 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)
}

View file

@ -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 }
}

View file

@ -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()
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.forceupdate
interface ForceUpdateFeatureToggles {
val isForceUpdateEnabled: Boolean
}

1
features/force-update/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View 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)
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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 = {},
),
)
}
}

View file

@ -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 }
}

View file

@ -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")