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

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

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