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

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