Updated on 2026-08-14
This commit is contained in:
parent
26bba893ff
commit
51e650c360
47 changed files with 1628 additions and 73 deletions
|
|
@ -124,6 +124,7 @@ dependencies {
|
|||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.wallets.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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.tap.features.root
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
internal class DefaultRootWarningContinuation @Inject constructor() : RootWarningContinuation {
|
||||
|
||||
private val dismissals = Channel<Unit>(capacity = Channel.CONFLATED)
|
||||
|
||||
override suspend fun awaitDismiss() {
|
||||
dismissals.receive()
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
dismissals.trySend(Unit)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +1,40 @@
|
|||
package com.tangem.tap.features.root
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.tap.features.root
|
||||
|
||||
/**
|
||||
* Resumes the startup gate after the root-detected security warning is dismissed.
|
||||
*
|
||||
* The gate awaits [awaitDismiss] while the warning is shown, and the screen calls [dismiss] on "Continue".
|
||||
*/
|
||||
interface RootWarningContinuation {
|
||||
|
||||
suspend fun awaitDismiss()
|
||||
|
||||
fun dismiss()
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.tap.features.root.di
|
||||
|
||||
import com.tangem.tap.features.root.DefaultRootWarningContinuation
|
||||
import com.tangem.tap.features.root.RootWarningContinuation
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface RootModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindRootWarningContinuation(impl: DefaultRootWarningContinuation): RootWarningContinuation
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ internal fun RootContent(
|
|||
modifier: Modifier = Modifier,
|
||||
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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue