Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-18 13:35:11 +03:00
parent 54f0be3e6c
commit 587a31520d
12 changed files with 274 additions and 53 deletions

View file

@ -0,0 +1,68 @@
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
@Suppress("UnusedPrivateProperty")
class RootDetectedWarningComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Unit,
private val securityInfoProvider: DeviceSecurityInfoProvider,
private val settingsRepository: SettingsRepository,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val isShown = instanceKeeper.getOrCreateSimple { MutableStateFlow(false) }
suspend fun tryToShowWarningAndWaitContinuation() {
if (isShown.value) return
if (settingsRepository.isRootDetectedWarningShown().not() && securityInfoProvider.isSecurityExposed()) {
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 },
)
}
}
}
private fun onContinueClick() {
componentScope.launch {
settingsRepository.setRootDetectedWarningShown(true)
isShown.value = false
}
}
@AssistedFactory
interface Factory : ComponentFactory<Unit, RootDetectedWarningComponent> {
override fun create(context: AppComponentContext, params: Unit): RootDetectedWarningComponent
}
}

View file

@ -0,0 +1,93 @@
package com.tangem.tap.features.root
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.icons.HighlightedIcon
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.wallet.R
@Composable
internal fun RootDetectedWarningContent(modifier: Modifier = Modifier, onContinueClick: () -> Unit = {}) {
Column(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.statusBarsPadding()
.padding(horizontal = 16.dp),
) {
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
) {
InfoBlock(
modifier = Modifier.padding(top = 48.dp, bottom = 24.dp),
)
}
PrimaryButton(
modifier = Modifier
.navigationBarsPadding()
.padding(bottom = 16.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.common_understand_continue),
onClick = onContinueClick,
)
}
}
@Composable
private fun InfoBlock(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
HighlightedIcon(
icon = R.drawable.ic_alert_circle_24,
iconTint = TangemTheme.colors.icon.warning,
)
SpacerH(20.dp)
Text(
text = stringResourceSafe(R.string.root_detected_warning_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
SpacerH(12.dp)
Text(
modifier = Modifier.padding(horizontal = 24.dp),
text = stringResourceSafe(R.string.root_detected_warning_description),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}
}
@Preview
@Composable
private fun Preview() {
TangemThemePreview {
RootDetectedWarningContent()
}
}

View file

@ -47,6 +47,7 @@ internal fun RootContent(
modifier: Modifier = Modifier,
wcContent: @Composable (modifier: Modifier) -> Unit,
hotAccessCodeContent: @Composable (modifier: Modifier) -> Unit,
rootDetectedWarningContent: @Composable (modifier: Modifier) -> Unit,
) {
val context = LocalContext.current
@ -82,6 +83,8 @@ internal fun RootContent(
hotAccessCodeContent(Modifier.fillMaxSize())
rootDetectedWarningContent(Modifier.fillMaxSize())
TangemSnackbarHost(
modifier = Modifier
.align(Alignment.BottomCenter)

View file

@ -38,6 +38,7 @@ import com.tangem.tap.common.SnackbarHandler
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.hot.TangemHotSDKProxy
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupDialog
import com.tangem.tap.features.root.RootDetectedWarningComponent
import com.tangem.tap.routing.RootContent
import com.tangem.tap.routing.component.RoutingComponent
import com.tangem.tap.routing.component.RoutingComponent.Child
@ -64,6 +65,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 userWalletsListRepository: UserWalletsListRepository,
private val cardRepository: CardRepository,
private val onboardingRepository: OnboardingRepository,
@ -85,6 +87,11 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
.create(child("hotAccessCodeRequestComponent"), Unit)
}
private val rootDetectedWarningComponent: RootDetectedWarningComponent by lazy {
rootDetectedWarningComponentFactory
.create(child("rootDetectedWarningComponent"), Unit)
}
private val navigation = navigationProvider.getOrCreateTyped<AppRoute>()
private val stack: Value<ChildStack<AppRoute, Child>> = childStack(
@ -134,6 +141,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private fun initializeInitialNavigation() {
if (initialStack.isNullOrEmpty()) {
componentScope.launch {
rootDetectedWarningComponent.tryToShowWarningAndWaitContinuation()
val initialRoute = resolveInitialRoute()
router.replaceAll(initialRoute)
}
@ -177,6 +185,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
modifier = modifier,
wcContent = { wcRoutingComponent.Content(it) },
hotAccessCodeContent = { hotAccessCodeRequestComponent.Content(it) },
rootDetectedWarningContent = { rootDetectedWarningComponent.Content(it) },
)
}

View file

@ -27,6 +27,8 @@ object PreferencesKeys {
val SAVE_USER_WALLETS_KEY by lazy { booleanPreferencesKey(name = "saveUserWallets") }
val ROOT_DETECTED_WARNING_SHOWN_KEY by lazy { booleanPreferencesKey(name = "rootDetectedWarningShown") }
val SHOULD_SHOW_ASK_BIOMETRY_KEY by lazy { booleanPreferencesKey("saveUserWalletShown") }
val APP_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "launchCount") }

View file

@ -2017,7 +2017,7 @@
<string name="yield_module_transaction_exit_subtitle">%1$s выведено из Aave</string>
<string name="yield_module_transaction_initialize">Режим доходности инициализирован</string>
<string name="yield_module_transaction_reactivate">Режим доходности реактивирован</string>
<string name="yield_module_transaction_topup">Перевод средств в Aave</string>
<string name="yield_module_transaction_topup">Перевод в Aave</string>
<string name="yield_module_transaction_topup_subtitle">%1$s отправлено в Aave</string>
<string name="yield_module_transaction_withdraw">Вывод из Aave</string>
<string name="yield_module_transfer_mode_automatic">Автоматически</string>

View file

@ -1091,6 +1091,8 @@
<string name="reset_cards_dialog_next_device_description">Please reset the next device to continue</string>
<string name="ring_promo_text">Ring owners get 3 commission-free swaps on Changelly until 15.11!</string>
<string name="ring_promo_title">Swap With 0% Fees Now!</string>
<string name="root_detected_warning_description">Devices with root access are considered less secure. Your data may be exposed to additional risks.</string>
<string name="root_detected_warning_title">Root access detected</string>
<string name="save_user_wallet_agreement_access_description">Log into the app and check your balance without scanning the card or ring</string>
<string name="save_user_wallet_agreement_access_title">Access the app</string>
<string name="save_user_wallet_agreement_allow_biometrics">Allow to use biometrics</string>

View file

@ -37,44 +37,46 @@ fun DialogFullScreen(
decorFitsSystemWindows = false,
),
content = {
val activityWindow = getActivityWindow()
val dialogWindow = getDialogWindow()
val parentView = LocalView.current.parent as View
SideEffect {
if (activityWindow != null && dialogWindow != null) {
val attributes = WindowManager.LayoutParams().apply {
copyFrom(activityWindow.attributes)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
} else {
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
}
type = dialogWindow.attributes.type
}
dialogWindow.attributes = attributes
parentView.layoutParams =
FrameLayout.LayoutParams(
activityWindow.decorView.width,
activityWindow.decorView.height,
)
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
val systemUiController = rememberSystemUiController(getActivityWindow())
val dialogSystemUiController = rememberSystemUiController(getDialogWindow())
ProvideSystemBarsIconsController {
val activityWindow = getActivityWindow()
val dialogWindow = getDialogWindow()
val parentView = LocalView.current.parent as View
SideEffect {
systemUiController.setSystemBarsColor(color = Color.Transparent)
dialogSystemUiController.setSystemBarsColor(color = Color.Transparent)
if (activityWindow != null && dialogWindow != null) {
val attributes = WindowManager.LayoutParams().apply {
copyFrom(activityWindow.attributes)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
} else {
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
}
type = dialogWindow.attributes.type
}
dialogWindow.attributes = attributes
parentView.layoutParams =
FrameLayout.LayoutParams(
activityWindow.decorView.width,
activityWindow.decorView.height,
)
}
}
}
SystemBarsIconsDisposable(darkIcons = LocalIsInDarkTheme.current.not())
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
val systemUiController = rememberSystemUiController(getActivityWindow())
val dialogSystemUiController = rememberSystemUiController(getDialogWindow())
Surface(modifier = Modifier.fillMaxSize(), color = Color.Transparent) {
content()
SideEffect {
systemUiController.setSystemBarsColor(color = Color.Transparent)
dialogSystemUiController.setSystemBarsColor(color = Color.Transparent)
}
}
SystemBarsIconsDisposable(darkIcons = LocalIsInDarkTheme.current.not())
Surface(modifier = Modifier.fillMaxSize(), color = Color.Transparent) {
content()
}
}
},
)

View file

@ -2,17 +2,13 @@ package com.tangem.core.ui.components.bottomsheets.message
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -25,6 +21,7 @@ import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTi
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.icons.HighlightedIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@ -144,20 +141,11 @@ private fun BottomSheetIcon(icon: MessageBottomSheetUMV2.Icon, modifier: Modifie
MessageBottomSheetUMV2.Icon.BackgroundType.Warning -> TangemTheme.colors.icon.warning
}
Box(
modifier = modifier
.size(TangemTheme.dimens.size56)
.clip(CircleShape)
.background(backgroundColor.copy(alpha = 0.1F)),
contentAlignment = Alignment.Center,
content = {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size32),
painter = painterResource(icon.res),
contentDescription = null,
tint = tint,
)
},
HighlightedIcon(
modifier = modifier,
icon = icon.res,
iconTint = tint,
backgroundColor = backgroundColor,
)
}

View file

@ -0,0 +1,39 @@
package com.tangem.core.ui.components.icons
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.res.TangemTheme
@Composable
fun HighlightedIcon(
@DrawableRes icon: Int,
iconTint: Color,
modifier: Modifier = Modifier,
backgroundColor: Color = iconTint,
) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size56)
.clip(CircleShape)
.background(backgroundColor.copy(alpha = 0.1F)),
contentAlignment = Alignment.Center,
content = {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size32),
painter = painterResource(icon),
contentDescription = null,
tint = iconTint,
)
},
)
}

View file

@ -193,4 +193,15 @@ internal class DefaultSettingsRepository(
default = false,
)
}
override suspend fun isRootDetectedWarningShown(): Boolean {
return appPreferencesStore.getSyncOrDefault(
key = PreferencesKeys.ROOT_DETECTED_WARNING_SHOWN_KEY,
default = false,
)
}
override suspend fun setRootDetectedWarningShown(value: Boolean) {
appPreferencesStore.store(key = PreferencesKeys.ROOT_DETECTED_WARNING_SHOWN_KEY, value = value)
}
}

View file

@ -56,4 +56,8 @@ interface SettingsRepository {
suspend fun setGooglePayAvailability(value: Boolean)
suspend fun isGooglePayAvailability(): Boolean
suspend fun isRootDetectedWarningShown(): Boolean
suspend fun setRootDetectedWarningShown(value: Boolean)
}