Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-14 13:57:57 +03:00
commit f3dfb262c2
527 changed files with 12898 additions and 3301 deletions

View file

@ -1,7 +1,6 @@
package com.tangem.tap.common.redux
import com.tangem.tap.common.redux.global.globalReducer
import com.tangem.tap.features.details.redux.DetailsReducer
import com.tangem.tap.proxy.redux.DaggerGraphReducer
import org.rekotlin.Action
@ -10,7 +9,6 @@ fun appReducer(action: Action, state: AppState): AppState {
return AppState(
globalState = globalReducer(action, state),
detailsState = DetailsReducer.reduce(action, state),
daggerGraphState = DaggerGraphReducer.reduce(action, state),
)
}

View file

@ -1,10 +1,6 @@
package com.tangem.tap.common.redux
import com.tangem.tap.common.redux.global.GlobalMiddleware
import com.tangem.tap.common.redux.global.GlobalState
import com.tangem.tap.common.redux.legacy.LegacyMiddleware
import com.tangem.tap.features.details.redux.DetailsMiddleware
import com.tangem.tap.features.details.redux.DetailsState
import com.tangem.tap.proxy.redux.DaggerGraphMiddleware
import com.tangem.tap.proxy.redux.DaggerGraphState
import org.rekotlin.Middleware
@ -12,7 +8,6 @@ import org.rekotlin.StateType
data class AppState(
val globalState: GlobalState = GlobalState(),
val detailsState: DetailsState = DetailsState(),
val daggerGraphState: DaggerGraphState = DaggerGraphState(),
) : StateType {
@ -20,12 +15,9 @@ data class AppState(
fun getMiddleware(): List<Middleware<AppState>> {
return listOf(
logMiddleware,
GlobalMiddleware.handler,
DetailsMiddleware().detailsMiddleware,
LockUserWalletsTimerMiddleware().middleware,
AccessCodeRequestPolicyMiddleware().middleware,
DaggerGraphMiddleware.daggerGraphMiddleware,
LegacyMiddleware.legacyMiddleware,
)
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.tap.common.redux.global
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.scan.ScanResponse
import org.rekotlin.Action
@ -8,10 +7,5 @@ sealed class GlobalAction : Action {
data class SaveScanResponse(val scanResponse: ScanResponse) : GlobalAction()
data class ChangeAppCurrency(val appCurrency: AppCurrency) : GlobalAction()
object RestoreAppCurrency : GlobalAction() {
data class Success(val appCurrency: AppCurrency) : GlobalAction()
}
data class IsSignWithRing(val isSignWithRing: Boolean) : GlobalAction()
}

View file

@ -1,43 +0,0 @@
package com.tangem.tap.common.redux.global
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.scope
import com.tangem.tap.store
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.Middleware
object GlobalMiddleware {
val handler = globalMiddlewareHandler
}
private val globalMiddlewareHandler: Middleware<AppState> = { _, _ ->
{ nextDispatch ->
{ action ->
handleAction(action)
nextDispatch(action)
}
}
}
private fun handleAction(action: Action) {
when (action) {
is GlobalAction.RestoreAppCurrency -> restoreAppCurrency()
}
}
private fun restoreAppCurrency() {
scope.launch {
val currency = store.inject(DaggerGraphState::appCurrencyRepository)
.getSelectedAppCurrency()
.firstOrNull()
?: AppCurrency.Default
store.dispatchWithMain(GlobalAction.RestoreAppCurrency.Success(currency))
}
}

View file

@ -13,13 +13,6 @@ fun globalReducer(action: Action, state: AppState): GlobalState {
is GlobalAction.SaveScanResponse -> {
globalState.copy(scanResponse = action.scanResponse)
}
is GlobalAction.ChangeAppCurrency -> {
globalState.copy(appCurrency = action.appCurrency)
}
is GlobalAction.RestoreAppCurrency.Success -> {
globalState.copy(appCurrency = action.appCurrency)
}
is GlobalAction.IsSignWithRing -> globalState.copy(isLastSignWithRing = action.isSignWithRing)
else -> globalState
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.tap.common.redux.global
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.tap.domain.TapWalletManager
import org.rekotlin.StateType
@ -9,7 +8,6 @@ data class GlobalState(
@Deprecated("Use scan response from selected user wallet")
val scanResponse: ScanResponse? = null,
val tapWalletManager: TapWalletManager = TapWalletManager(),
val appCurrency: AppCurrency = AppCurrency.Default,
val isLastSignWithRing: Boolean = false,
) : StateType

View file

@ -1,79 +0,0 @@
package com.tangem.tap.common.redux.legacy
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.redux.LegacyAction
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.details.redux.AppSettingsState
import com.tangem.tap.features.details.redux.DetailsAction
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.tap.tangemSdkManager
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import org.rekotlin.Middleware
@Suppress("MemberNameEqualsClassName")
internal object LegacyMiddleware {
private val prepareDetailsScreenJobHolder = JobHolder()
val legacyMiddleware: Middleware<AppState> = { _, _ ->
{ next ->
{ action ->
when (action) {
is LegacyAction.PrepareDetailsScreen -> {
selectedUserWallet()
.distinctUntilChanged { old, new ->
if (old is UserWallet.Cold && new is UserWallet.Cold) {
old.walletId == new.walletId &&
old.scanResponse == new.scanResponse
} else {
old.walletId == new.walletId
}
}
.onEach { selectedUserWallet ->
val initializedAppSettingsStateContent = initializeAppSettingsState()
store.dispatchWithMain(
DetailsAction.PrepareScreen(
scanResponse = (selectedUserWallet as? UserWallet.Cold)?.scanResponse,
initializedAppSettingsState = initializedAppSettingsStateContent,
),
)
}
.flowOn(Dispatchers.IO)
.launchIn(scope)
.saveIn(prepareDetailsScreenJobHolder)
}
}
next(action)
}
}
}
private fun selectedUserWallet(): Flow<UserWallet> {
return store.inject(DaggerGraphState::userWalletsListRepository).selectedUserWallet.filterNotNull()
}
/**
* LEGACY: We need to initialize [AppSettingsState] async to avoid drawing blocking
* previously it was initialized in runBlocking and blocked details screen
*/
private suspend fun initializeAppSettingsState(): AppSettingsState {
return AppSettingsState(
selectedAppCurrency = store.state.globalState.appCurrency,
selectedThemeMode = store.inject(DaggerGraphState::appThemeModeRepository).getAppThemeMode().firstOrNull()
?: AppThemeMode.DEFAULT,
requireAccessCode = store.inject(DaggerGraphState::walletsRepository).requireAccessCode(),
useBiometricAuthentication = store.inject(DaggerGraphState::walletsRepository).useBiometricAuthentication(),
isHidingEnabled = store.inject(DaggerGraphState::balanceHidingRepository)
.getBalanceHidingSettings().isHidingEnabledInSettings,
needEnrollBiometrics = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true,
hasSecuredWallets = store.inject(DaggerGraphState::userWalletsListRepository).hasSecuredWallets(),
)
}
}

View file

@ -0,0 +1,52 @@
package com.tangem.tap.di.domain
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository
import com.tangem.domain.assetsdiscovery.usecase.AcknowledgeAssetsDiscoveryCompletionUseCase
import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase
import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
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 AssetsDiscoveryDomainModule {
@Provides
@Singleton
fun provideObserveAssetsDiscoveryUseCase(
assetsDiscoveryRepository: AssetsDiscoveryRepository,
): ObserveAssetsDiscoveryUseCase {
return ObserveAssetsDiscoveryUseCase(
assetsDiscoveryRepository = assetsDiscoveryRepository,
)
}
@Provides
@Singleton
fun provideAcknowledgeAssetsDiscoveryCompletionUseCase(
assetsDiscoveryRepository: AssetsDiscoveryRepository,
): AcknowledgeAssetsDiscoveryCompletionUseCase {
return AcknowledgeAssetsDiscoveryCompletionUseCase(
assetsDiscoveryRepository = assetsDiscoveryRepository,
)
}
@Provides
@Singleton
fun provideStartAssetsDiscoveryUseCase(
assetsDiscoveryRepository: AssetsDiscoveryRepository,
manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
appCoroutineScope: AppCoroutineScope,
): StartAssetsDiscoveryUseCase {
return StartAssetsDiscoveryUseCase(
assetsDiscoveryRepository = assetsDiscoveryRepository,
manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase,
appCoroutineScope = appCoroutineScope,
)
}
}

View file

@ -0,0 +1,78 @@
package com.tangem.tap.di.domain
import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase
import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.GetDynamicAddressesStatusUseCase
import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase
import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.DerivationsRepository
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 DynamicAddressesDomainModule {
@Provides
@Singleton
fun provideEnableDynamicAddressesUseCase(
dynamicAddressesRepository: DynamicAddressesRepository,
): EnableDynamicAddressesUseCase {
return EnableDynamicAddressesUseCase(dynamicAddressesRepository)
}
@Provides
@Singleton
fun provideDisableDynamicAddressesUseCase(
dynamicAddressesRepository: DynamicAddressesRepository,
): DisableDynamicAddressesUseCase {
return DisableDynamicAddressesUseCase(dynamicAddressesRepository)
}
@Provides
@Singleton
fun provideGetDynamicAddressesStatusUseCase(
dynamicAddressesRepository: DynamicAddressesRepository,
): GetDynamicAddressesStatusUseCase {
return GetDynamicAddressesStatusUseCase(dynamicAddressesRepository)
}
@Provides
@Singleton
fun provideGetDynamicReceiveAddressUseCase(
dynamicAddressesRepository: DynamicAddressesRepository,
): GetDynamicReceiveAddressUseCase {
return GetDynamicReceiveAddressUseCase(dynamicAddressesRepository)
}
@Provides
@Singleton
fun provideCreateConsolidationTransactionUseCase(
consolidationRepository: ConsolidationRepository,
): CreateConsolidationTransactionUseCase {
return CreateConsolidationTransactionUseCase(consolidationRepository)
}
@Provides
@Singleton
fun provideIsXpubSupportedUseCase(walletManagersFacade: WalletManagersFacade): IsXpubSupportedUseCase {
return IsXpubSupportedUseCase(walletManagersFacade)
}
@Provides
@Singleton
fun provideGetDerivedXpubUseCase(
walletManagersFacade: WalletManagersFacade,
derivationsRepository: DerivationsRepository,
): GetDerivedXpubUseCase {
return GetDerivedXpubUseCase(walletManagersFacade, derivationsRepository)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.tap.di.domain
import com.tangem.domain.staking.*
import com.tangem.domain.staking.repositories.*
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.staking.single.SingleStakingBalanceFetcher
import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -226,8 +227,14 @@ internal object StakingDomainModule {
@Provides
@Singleton
fun provideStakingIdFactory(walletManagersFacade: WalletManagersFacade): StakingIdFactory {
return StakingIdFactory(walletManagersFacade = walletManagersFacade)
fun provideStakingIdFactory(
walletManagersFacade: WalletManagersFacade,
stakingFeatureToggles: StakingFeatureToggles,
): StakingIdFactory {
return StakingIdFactory(
walletManagersFacade = walletManagersFacade,
stakingFeatureToggles = stakingFeatureToggles,
)
}
@Provides

View file

@ -1,50 +0,0 @@
package com.tangem.tap.di.domain
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.tokensync.repository.TokenSyncRepository
import com.tangem.domain.tokensync.usecase.AcknowledgeTokenSyncCompletionUseCase
import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase
import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
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 TokenSyncDomainModule {
@Provides
@Singleton
fun provideObserveTokenSyncUseCase(tokenSyncRepository: TokenSyncRepository): ObserveTokenSyncUseCase {
return ObserveTokenSyncUseCase(
tokenSyncRepository = tokenSyncRepository,
)
}
@Provides
@Singleton
fun provideAcknowledgeTokenSyncCompletionUseCase(
tokenSyncRepository: TokenSyncRepository,
): AcknowledgeTokenSyncCompletionUseCase {
return AcknowledgeTokenSyncCompletionUseCase(
tokenSyncRepository = tokenSyncRepository,
)
}
@Provides
@Singleton
fun provideStartTokenSyncUseCase(
tokenSyncRepository: TokenSyncRepository,
manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
appCoroutineScope: AppCoroutineScope,
): StartTokenSyncUseCase {
return StartTokenSyncUseCase(
tokenSyncRepository = tokenSyncRepository,
manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase,
appCoroutineScope = appCoroutineScope,
)
}
}

View file

@ -5,6 +5,9 @@ import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.notifications.repository.PushNotificationsRepository
@ -257,10 +260,16 @@ internal object TransactionDomainModule {
fun provideReceiveAddressesFactory(
getEnsNameUseCase: GetEnsNameUseCase,
getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase,
getDynamicReceiveAddressUseCase: GetDynamicReceiveAddressUseCase,
dynamicAddressesRepository: DynamicAddressesRepository,
dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
): ReceiveAddressesFactory {
return ReceiveAddressesFactory(
getEnsNameUseCase = getEnsNameUseCase,
getViewedTokenReceiveWarningUseCase = getViewedTokenReceiveWarningUseCase,
getDynamicReceiveAddressUseCase = getDynamicReceiveAddressUseCase,
dynamicAddressesRepository = dynamicAddressesRepository,
dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles,
)
}

View file

@ -10,6 +10,7 @@ import com.tangem.common.KeyPair
import com.tangem.common.SuccessResponse
import com.tangem.common.authentication.keystore.DummyKeystoreManager
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.TangemSdkError
import com.tangem.common.core.UserCodeRequestPolicy
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.services.InMemoryStorage
@ -32,6 +33,8 @@ import com.tangem.sdk.api.TangemSdkManager
import com.tangem.sdk.api.visa.VisaCardActivationResponse
import com.tangem.sdk.api.visa.VisaCardActivationTaskMode
import com.tangem.tap.domain.sdk.mocks.MockProvider
import com.tangem.tap.domain.sdk.mocks.showMockCardPicker
import com.tangem.tap.foregroundActivityObserver
@Suppress("TooManyFunctions")
class MockTangemSdkManager(
@ -61,6 +64,17 @@ class MockTangemSdkManager(
allowsRequestAccessCodeFromRepository: Boolean,
shouldCheckIsAlreadyActivated: Boolean,
): CompletionResult<ScanResponse> {
if (!MockProvider.isPreset) {
val activity = foregroundActivityObserver.foregroundActivity
if (activity != null) {
val selectedMock = showMockCardPicker(activity)
if (selectedMock != null) {
MockProvider.setMocksWithoutPresetFlag(selectedMock)
} else {
return CompletionResult.Failure(TangemSdkError.UserCancelled())
}
}
}
return MockProvider.getScanResponse()
}

View file

@ -0,0 +1,33 @@
package com.tangem.tap.domain.sdk.mocks
import androidx.appcompat.app.AlertDialog
import com.tangem.wallet.R
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
internal suspend fun showMockCardPicker(activity: AppCompatActivity): MockContent? = withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation ->
val mocks = MockProvider.availableMocks
val names = mocks.map { it.first }.toTypedArray()
val dialog = AlertDialog.Builder(activity)
.setTitle(R.string.mock_card_picker_title)
.setItems(names) { _, which ->
if (continuation.isActive) {
continuation.resume(mocks[which].second)
}
}
.setOnCancelListener {
if (continuation.isActive) {
continuation.resume(null)
}
}
.create()
continuation.invokeOnCancellation { activity.runOnUiThread { dialog.dismiss() } }
dialog.show()
}
}

View file

@ -11,10 +11,32 @@ object MockProvider {
private var content: MockContent = getMockContent(ProductType.Wallet)
var isPreset: Boolean = false
private set
private var isEmulatingError: Boolean = false
private var emulatedError: TangemError = TangemSdkError.TagLost()
val availableMocks: List<Pair<String, MockContent>> = listOf(
"Wallet" to WalletMockContent,
"Note" to NoteMockContent,
"Twins" to TwinsMockContent,
"Ring" to RingMockContent,
"Wallet 2" to Wallet2MockContent,
"Wallet 2 (No Backup)" to Wallet2NoBackupMockContent,
"Wallet 2 (No Backup, No Wallets)" to Wallet2NoBackupNoWalletsMockContent,
"Wallet 2 (Seed Phrase)" to Wallet2WithSeedPhraseMockContent,
"Shiba" to ShibaMockContent,
"Shiba (No Backup)" to ShibaNoBackupMockContent,
"Shiba (No Backup, No Wallets)" to ShibaNoBackupNoWalletsMockContent,
"Ed25519 Curve" to EdCurveMockContent,
"Secp256k1 Curve" to Secpk1CurveMockContent,
"Backup Wallet" to BackupWalletMockContent,
"Dev Wallet" to DevWalletMockContent,
"Firmware 4.12" to Firmware412MockContent,
)
fun setEmulateError(error: TangemError? = null) {
isEmulatingError = true
error?.let {
@ -28,10 +50,16 @@ object MockProvider {
fun setMocks(productType: ProductType) {
content = getMockContent(productType)
isPreset = true
}
fun setMocks(mockContent: MockContent) {
content = mockContent
isPreset = true
}
fun setMocksWithoutPresetFlag(mockContent: MockContent) {
content = mockContent
}
fun getSuccessResponse() = CompletionResult.Success(content.successResponse).orFailure()

View file

@ -33,7 +33,7 @@ class FinalizeTwinTask(
visaCardScanHandler = null,
visaCoroutineScope = null,
shouldCheckIsAlreadyActivated = false,
isDynamicAddressesEnabled = false,
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
onboardingV2FeatureToggles = null,
).run(session, callback)
is CompletionResult.Failure ->

View file

@ -1,55 +0,0 @@
package com.tangem.tap.features.details.redux
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.models.scan.ScanResponse
import kotlinx.coroutines.CoroutineScope
import org.rekotlin.Action
@Suppress("BooleanPropertyNaming")
sealed class DetailsAction : Action {
data class PrepareScreen(
val scanResponse: ScanResponse?,
val initializedAppSettingsState: AppSettingsState,
) : DetailsAction()
sealed class AppSettings : DetailsAction() {
data class SwitchPrivacySetting(
val enable: Boolean,
val setting: AppSetting,
) : AppSettings() {
data object Success : AppSettings()
data class Failure(
val prevState: Boolean,
val setting: AppSetting,
) : AppSettings()
}
data class CheckBiometricsStatus(
val coroutineScope: CoroutineScope,
) : AppSettings()
data object EnrollBiometrics : AppSettings()
data class BiometricsStatusChanged(
val isEnrollBiometricsNeeded: Boolean,
) : AppSettings()
data class ChangeAppThemeMode(
val appThemeMode: AppThemeMode,
) : AppSettings()
data class ChangeBalanceHiding(
val shouldHideBalance: Boolean,
) : AppSettings()
data class ChangeAppCurrency(
val currency: AppCurrency,
) : AppSettings()
data class Prepare(val state: AppSettingsState) : AppSettings()
}
data class ChangeAppCurrency(val currency: AppCurrency) : DetailsAction()
}

View file

@ -1,235 +0,0 @@
package com.tangem.tap.features.details.redux
import com.tangem.common.CompletionResult
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.core.analytics.Analytics
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.tap.tangemSdkManager
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.Middleware
@Suppress("MemberNameEqualsClassName")
class DetailsMiddleware {
private val appSettingsMiddleware = AppSettingsMiddleware()
val detailsMiddleware: Middleware<AppState> = { _, stateProvider ->
{ next ->
{ action ->
if (!DemoHelper.tryHandle(stateProvider)) {
val detailsState = stateProvider()?.detailsState
if (detailsState != null) {
handleAction(action)
}
}
next(action)
}
}
}
private fun handleAction(action: Action) {
when (action) {
is DetailsAction.AppSettings -> appSettingsMiddleware.handle(action)
}
}
class AppSettingsMiddleware {
private val checkBiometricsStatusJobHolder = JobHolder()
fun handle(action: DetailsAction.AppSettings) {
when (action) {
is DetailsAction.AppSettings.SwitchPrivacySetting -> {
when (action.setting) {
AppSetting.RequireAccessCode -> toggleRequireAccessCode(enable = action.enable)
AppSetting.BiometricAuthentication -> toggleBiometricsAuthentication(enable = action.enable)
}
}
is DetailsAction.AppSettings.CheckBiometricsStatus -> {
observeBiometricsStatusChanges(action.coroutineScope)
}
is DetailsAction.AppSettings.EnrollBiometrics -> {
enrollBiometrics()
}
is DetailsAction.AppSettings.ChangeAppThemeMode -> {
changeAppThemeMode(action.appThemeMode)
}
is DetailsAction.AppSettings.ChangeBalanceHiding -> {
changeBalanceHiding(action.shouldHideBalance)
}
is DetailsAction.AppSettings.ChangeAppCurrency -> {
store.dispatch(GlobalAction.ChangeAppCurrency(action.currency))
store.dispatch(DetailsAction.ChangeAppCurrency(action.currency))
}
is DetailsAction.AppSettings.SwitchPrivacySetting.Success,
is DetailsAction.AppSettings.SwitchPrivacySetting.Failure,
is DetailsAction.AppSettings.BiometricsStatusChanged,
is DetailsAction.AppSettings.Prepare,
-> Unit
}
}
private fun toggleBiometricsAuthentication(enable: Boolean) {
scope.launch {
val walletsRepository = store.inject(DaggerGraphState::walletsRepository)
// Nothing to change
if (walletsRepository.useBiometricAuthentication() == enable) {
store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success)
return@launch
}
if (enable) {
setBiometricLockForAllWallets()
} else {
// Remove all biometric-related data
removeAllBiometricData()
walletsRepository.setRequireAccessCode(value = true)
}
walletsRepository.setUseBiometricAuthentication(value = enable)
store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success)
}
}
private fun toggleRequireAccessCode(enable: Boolean) {
scope.launch {
val walletsRepository = store.inject(DaggerGraphState::walletsRepository)
// Nothing to change
if (walletsRepository.requireAccessCode() == enable) {
store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success)
return@launch
}
if (enable) {
// Remove all saved access codes
removeAllBiometricSingData()
}
walletsRepository.setRequireAccessCode(value = enable)
store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success)
}
}
private suspend fun setBiometricLockForAllWallets() {
val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository)
val userWallets = userWalletsListRepository.userWalletsSync()
userWallets.forEach { wallet ->
userWalletsListRepository.setLock(
userWalletId = wallet.walletId,
lockMethod = LockMethod.Biometric,
changeUnsecured = false,
)
}
}
private suspend fun removeAllBiometricData() {
val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository)
userWalletsListRepository.userWalletsSync().forEach {
userWalletsListRepository.removeBiometricLock(it.walletId)
}
removeAllBiometricSingData()
}
private suspend fun removeAllBiometricSingData() {
deleteSavedAccessCodes()
val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository)
val tangemHotSdk = store.inject(DaggerGraphState::tangemHotSdk)
userWalletsListRepository.userWalletsSync().forEach { wallet ->
if (wallet is UserWallet.Hot) {
userWalletsListRepository.saveWithoutLock(
userWallet = wallet.copy(
hotWalletId = tangemHotSdk.removeBiometryAuthIfPresented(wallet.hotWalletId),
),
)
}
}
}
private fun observeBiometricsStatusChanges(scope: CoroutineScope) {
val needEnrollBiometricsFlow = flow {
do {
val isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull()
if (isEnrollBiometricsNeeded != null) {
emit(isEnrollBiometricsNeeded)
}
delay(timeMillis = 200)
} while (true)
}
needEnrollBiometricsFlow
.distinctUntilChanged()
.onEach { needEnrollBiometrics ->
store.dispatchWithMain(DetailsAction.AppSettings.BiometricsStatusChanged(needEnrollBiometrics))
}
.launchIn(scope)
.saveIn(checkBiometricsStatusJobHolder)
}
private fun enrollBiometrics() {
Analytics.send(Settings.AppSettings.ButtonEnableBiometricAuthentication())
store.inject(DaggerGraphState::settingsManager).openBiometricSettings()
}
private fun changeAppThemeMode(appThemeMode: AppThemeMode) {
val repository = store.inject(DaggerGraphState::appThemeModeRepository)
scope.launch {
repository.changeAppThemeMode(appThemeMode)
}
}
private fun changeBalanceHiding(hideBalance: Boolean) {
val repository = store.inject(DaggerGraphState::balanceHidingRepository)
scope.launch {
val newState = repository.getBalanceHidingSettings().copy(
isHidingEnabledInSettings = hideBalance,
isBalanceHidden = false,
)
repository.storeBalanceHidingSettings(newState)
}
}
private suspend fun deleteSavedAccessCodes(): CompletionResult<Unit> {
return tangemSdkManager.clearSavedUserCodes()
.doOnSuccess {
Analytics.send(Settings.AppSettings.SaveAccessCodeSwitcherChanged(AnalyticsParam.OnOffState.Off))
store.inject(DaggerGraphState::settingsRepository).setShouldSaveAccessCodes(value = false)
store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = false,
)
}
.doOnFailure { error ->
TangemLogger.e("Unable to delete saved access codes", error)
}
}
}
}

View file

@ -1,103 +0,0 @@
package com.tangem.tap.features.details.redux
import com.tangem.tap.common.redux.AppState
import org.rekotlin.Action
object DetailsReducer {
fun reduce(action: Action, state: AppState): DetailsState = internalReduce(action, state)
}
@Suppress("CyclomaticComplexMethod")
private fun internalReduce(action: Action, state: AppState): DetailsState {
if (action !is DetailsAction) return state.detailsState
val detailsState = state.detailsState
return when (action) {
is DetailsAction.PrepareScreen -> {
handlePrepareScreen(action)
}
is DetailsAction.AppSettings -> {
handlePrivacyAction(action, detailsState)
}
is DetailsAction.ChangeAppCurrency -> detailsState.copy(
appSettingsState = detailsState.appSettingsState.copy(
selectedAppCurrency = action.currency,
),
)
}
}
private fun handlePrepareScreen(action: DetailsAction.PrepareScreen): DetailsState {
return DetailsState(
scanResponse = action.scanResponse,
appSettingsState = action.initializedAppSettingsState,
)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: DetailsState): DetailsState {
return when (action) {
is DetailsAction.AppSettings.SwitchPrivacySetting -> state.copy(
appSettingsState = when (action.setting) {
AppSetting.RequireAccessCode -> state.appSettingsState.copy(
isInProgress = true,
requireAccessCode = action.enable,
)
AppSetting.BiometricAuthentication -> state.appSettingsState.copy(
isInProgress = true,
useBiometricAuthentication = action.enable,
)
},
)
is DetailsAction.AppSettings.SwitchPrivacySetting.Success -> state.copy(
appSettingsState = state.appSettingsState.copy(
isInProgress = false,
),
)
is DetailsAction.AppSettings.SwitchPrivacySetting.Failure -> state.copy(
appSettingsState = when (action.setting) {
AppSetting.RequireAccessCode -> state.appSettingsState.copy(
isInProgress = false,
requireAccessCode = action.prevState,
)
AppSetting.BiometricAuthentication -> state.appSettingsState.copy(
isInProgress = false,
needEnrollBiometrics = action.prevState,
)
},
)
is DetailsAction.AppSettings.BiometricsStatusChanged -> state.copy(
appSettingsState = state.appSettingsState.copy(
needEnrollBiometrics = action.isEnrollBiometricsNeeded,
),
)
is DetailsAction.AppSettings.ChangeAppThemeMode -> state.copy(
appSettingsState = state.appSettingsState.copy(
selectedThemeMode = action.appThemeMode,
),
)
is DetailsAction.AppSettings.ChangeAppCurrency -> state.copy(
appSettingsState = state.appSettingsState.copy(
selectedAppCurrency = action.currency,
),
)
is DetailsAction.AppSettings.ChangeBalanceHiding -> state.copy(
appSettingsState = state.appSettingsState.copy(
isHidingEnabled = action.shouldHideBalance,
),
)
// state should be copied to avoid concurrent modifications from different sources
is DetailsAction.AppSettings.Prepare -> state.copy(
appSettingsState = state.appSettingsState.copy(
isHidingEnabled = action.state.isHidingEnabled,
selectedAppCurrency = action.state.selectedAppCurrency,
selectedThemeMode = action.state.selectedThemeMode,
useBiometricAuthentication = action.state.useBiometricAuthentication,
requireAccessCode = action.state.requireAccessCode,
hasSecuredWallets = action.state.hasSecuredWallets,
),
)
is DetailsAction.AppSettings.EnrollBiometrics,
is DetailsAction.AppSettings.CheckBiometricsStatus,
-> state
}
}

View file

@ -1,30 +0,0 @@
package com.tangem.tap.features.details.redux
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.models.scan.ScanResponse
import org.rekotlin.StateType
data class DetailsState(
@Deprecated("Delete after onboarding refactoring")
val scanResponse: ScanResponse? = null,
val appSettingsState: AppSettingsState = AppSettingsState(),
) : StateType
@Suppress("BooleanPropertyNaming")
data class AppSettingsState(
val requireAccessCode: Boolean = false,
val useBiometricAuthentication: Boolean = false,
val needEnrollBiometrics: Boolean = false,
val hasSecuredWallets: Boolean = false,
val isHidingEnabled: Boolean = false,
val isInProgress: Boolean = false,
val selectedAppCurrency: AppCurrency = AppCurrency.Default,
val selectedThemeMode: AppThemeMode = AppThemeMode.DEFAULT,
)
enum class SecurityOption { LongTap, PassCode, AccessCode }
enum class AppSetting {
RequireAccessCode, BiometricAuthentication,
}

View file

@ -0,0 +1,3 @@
package com.tangem.tap.features.details.redux
enum class SecurityOption { LongTap, PassCode, AccessCode }

View file

@ -2,71 +2,59 @@ package com.tangem.tap.features.details.ui.appsettings
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Dialog
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.wallet.R
import kotlinx.collections.immutable.toImmutableList
internal class AppSettingsDialogsFactory {
fun createThemeModeSelectorDialog(
selectedModeIndex: Int,
onSelect: (AppThemeMode) -> Unit,
onDismiss: () -> Unit,
): Dialog.Selector {
val modes = AppThemeMode.available
return Dialog.Selector(
title = resourceReference(R.string.app_settings_theme_selector_title),
selectedItemIndex = selectedModeIndex,
items = modes.map { mode ->
resourceReference(
id = when (mode) {
AppThemeMode.FORCE_DARK -> R.string.app_settings_theme_mode_dark
AppThemeMode.FORCE_LIGHT -> R.string.app_settings_theme_mode_light
AppThemeMode.FOLLOW_SYSTEM -> R.string.app_settings_theme_mode_system
},
)
}.toImmutableList(),
onSelect = { index ->
val mode = AppThemeMode.available[index]
onSelect(mode)
},
onDismiss = onDismiss,
)
}
fun createDisableBiometricAuthenticationAlert(onDisable: () -> Unit, onDismiss: () -> Unit): Dialog.Alert {
return Dialog.Alert(
fun createDisableBiometricAuthenticationAlert(onDisable: () -> Unit): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.common_attention),
description = resourceReference(
message = resourceReference(
R.string.app_settings_off_biometrics_alert_message,
wrappedList(resourceReference(R.string.common_biometrics)),
),
confirmText = resourceReference(R.string.common_disable),
onConfirm = onDisable,
onDismiss = onDismiss,
isDismissable = false,
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_disable),
isWarning = true,
onClick = onDisable,
)
},
secondActionBuilder = { cancelAction() },
)
}
fun createEnableRequireAccessCodeAlert(onEnable: () -> Unit, onDismiss: () -> Unit): Dialog.Alert {
return Dialog.Alert(
fun createEnableRequireAccessCodeAlert(onEnable: () -> Unit): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.common_attention),
description = resourceReference(R.string.app_settings_on_require_access_code_alert_message),
confirmText = resourceReference(R.string.common_enable),
onConfirm = { onEnable() },
onDismiss = onDismiss,
message = resourceReference(R.string.app_settings_on_require_access_code_alert_message),
isDismissable = false,
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_enable),
onClick = onEnable,
)
},
secondActionBuilder = { cancelAction() },
)
}
fun createDisableRequireAccessCodeAlert(onDisable: () -> Unit, onDismiss: () -> Unit): Dialog.Alert {
return Dialog.Alert(
fun createDisableRequireAccessCodeAlert(onDisable: () -> Unit): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.common_attention),
description = resourceReference(R.string.app_settings_off_require_access_code_alert_message),
confirmText = resourceReference(R.string.common_disable),
onConfirm = { onDisable() },
onDismiss = onDismiss,
message = resourceReference(R.string.app_settings_off_require_access_code_alert_message),
isDismissable = false,
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_disable),
isWarning = true,
onClick = onDisable,
)
},
secondActionBuilder = { cancelAction() },
)
}
}

View file

@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
@ -42,13 +40,6 @@ internal fun AppSettingsScreen(state: AppSettingsScreenState, onBackClick: () ->
@Composable
private fun AppSettings(state: AppSettingsScreenState.Content) {
val dialog by rememberUpdatedState(newValue = state.dialog)
when (val safeDialog = dialog) {
is AppSettingsScreenState.Dialog.Alert -> SettingsAlertDialog(dialog = safeDialog)
is AppSettingsScreenState.Dialog.Selector -> SettingsSelectorDialog(dialog = safeDialog)
null -> Unit
}
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
LazyColumn(
@ -102,12 +93,7 @@ private class AppSettingsScreenStateProvider : CollectionPreviewParameterProvide
itemsFactory.createSelectThemeModeButton(AppThemeMode.DEFAULT, {}),
)
add(
AppSettingsScreenState.Content(
items = items,
dialog = null,
),
)
add(AppSettingsScreenState.Content(items = items))
},
)
// endregion Preview

View file

@ -10,10 +10,7 @@ internal sealed class AppSettingsScreenState {
object Loading : AppSettingsScreenState()
data class Content(
val items: ImmutableList<Item>,
val dialog: Dialog?,
) : AppSettingsScreenState()
data class Content(val items: ImmutableList<Item>) : AppSettingsScreenState()
@Immutable
sealed class Item {
@ -46,26 +43,4 @@ internal sealed class AppSettingsScreenState {
val onClick: () -> Unit,
) : Item()
}
@Immutable
sealed class Dialog {
abstract val onDismiss: () -> Unit
data class Alert(
val title: TextReference,
val description: TextReference,
val confirmText: TextReference,
val onConfirm: () -> Unit,
override val onDismiss: () -> Unit,
) : Dialog()
data class Selector(
val title: TextReference,
val selectedItemIndex: Int,
val items: ImmutableList<TextReference>,
val onSelect: (Int) -> Unit,
override val onDismiss: () -> Unit,
) : Dialog()
}
}

View file

@ -4,42 +4,56 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.essenty.lifecycle.doOnResume
import com.tangem.common.routing.AppRouter
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.features.details.ui.appsettings.api.AppSettingsComponent
import com.tangem.tap.features.details.ui.appsettings.model.AppSettingsDialogConfig
import com.tangem.tap.features.details.ui.appsettings.model.AppSettingsModel
import com.tangem.tap.store
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
internal class DefaultAppSettingsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Unit,
@Suppress("UnusedPrivateMember") @Assisted params: Unit,
) : AppSettingsComponent, AppComponentContext by appComponentContext {
private val model: AppSettingsModel = getOrCreateModel()
init {
private val dialogSlot = childSlot(
source = model.dialogNavigation,
serializer = AppSettingsDialogConfig.serializer(),
handleBackButton = true,
childFactory = { config, _ -> config },
)
init {
doOnResume { model.onResume() }
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val dialog by dialogSlot.subscribeAsState()
AppSettingsScreen(
modifier = modifier,
state = state,
onBackClick = {
store.dispatchNavigationAction(AppRouter::pop)
},
onBackClick = model::onBackClick,
)
dialog.child?.instance?.let { config ->
when (config) {
is AppSettingsDialogConfig.ThemeModeSelector -> SettingsSelectorDialog(
config = config,
onSelect = model::onThemeModeSelected,
onDismiss = model::dismissDialog,
)
}
}
}
@AssistedFactory

View file

@ -0,0 +1,38 @@
package com.tangem.tap.features.details.ui.appsettings
import androidx.compose.runtime.Composable
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.components.SelectorDialog
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.tap.features.details.ui.appsettings.model.AppSettingsDialogConfig
import com.tangem.wallet.R
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun SettingsSelectorDialog(
config: AppSettingsDialogConfig.ThemeModeSelector,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val modes = AppThemeMode.available
SelectorDialog(
title = stringResourceSafe(R.string.app_settings_theme_selector_title),
selectedItemIndex = config.selectedModeIndex,
items = modes.map { mode ->
stringResourceSafe(
id = when (mode) {
AppThemeMode.FORCE_DARK -> R.string.app_settings_theme_mode_dark
AppThemeMode.FORCE_LIGHT -> R.string.app_settings_theme_mode_light
AppThemeMode.FOLLOW_SYSTEM -> R.string.app_settings_theme_mode_system
},
)
}.toImmutableList(),
confirmButton = DialogButtonUM(
title = stringResourceSafe(R.string.common_cancel),
onClick = onDismiss,
),
onSelect = onSelect,
onDismissDialog = onDismiss,
)
}

View file

@ -1,54 +0,0 @@
package com.tangem.tap.features.details.ui.appsettings.components
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.tap.features.details.ui.appsettings.AppSettingsDialogsFactory
import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Dialog
import com.tangem.wallet.R
@Composable
internal fun SettingsAlertDialog(dialog: Dialog.Alert) {
BasicDialog(
title = dialog.title.resolveReference(),
message = dialog.description.resolveReference(),
isDismissable = false,
confirmButton = DialogButtonUM(
title = dialog.confirmText.resolveReference(),
isWarning = true,
onClick = dialog.onConfirm,
),
dismissButton = DialogButtonUM(
title = stringResourceSafe(id = R.string.common_cancel),
onClick = dialog.onDismiss,
),
onDismissDialog = dialog.onDismiss,
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun AlertDialogPreview(@PreviewParameter(AlertDialogProvider::class) dialog: Dialog.Alert) {
TangemThemePreview {
SettingsAlertDialog(dialog = dialog)
}
}
private class AlertDialogProvider : CollectionPreviewParameterProvider<Dialog>(
collection = buildList {
val dialogsFactory = AppSettingsDialogsFactory()
add(dialogsFactory.createThemeModeSelectorDialog(selectedModeIndex = 0, onSelect = {}, onDismiss = {}))
add(dialogsFactory.createDisableBiometricAuthenticationAlert(onDisable = {}, onDismiss = {}))
},
)
// endregion Preview

View file

@ -1,52 +0,0 @@
package com.tangem.tap.features.details.ui.appsettings.components
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.components.SelectorDialog
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.tap.features.details.ui.appsettings.AppSettingsDialogsFactory
import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Dialog
import com.tangem.wallet.R
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun SettingsSelectorDialog(dialog: Dialog.Selector) {
SelectorDialog(
title = dialog.title.resolveReference(),
selectedItemIndex = dialog.selectedItemIndex,
items = dialog.items.map { it.resolveReference() }.toImmutableList(),
confirmButton = DialogButtonUM(
title = stringResourceSafe(R.string.common_cancel),
onClick = dialog.onDismiss,
),
onSelect = dialog.onSelect,
onDismissDialog = dialog.onDismiss,
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SettingsSelectorDialogPreview(@PreviewParameter(DialogProvider::class) param: Dialog.Selector) {
TangemThemePreview {
SettingsSelectorDialog(param)
}
}
private class DialogProvider : CollectionPreviewParameterProvider<Dialog.Selector>(
collection = listOf(
AppSettingsDialogsFactory().createThemeModeSelectorDialog(
selectedModeIndex = 0,
onSelect = {},
onDismiss = {},
),
),
)
// endregion Preview

View file

@ -0,0 +1,10 @@
package com.tangem.tap.features.details.ui.appsettings.model
import kotlinx.serialization.Serializable
@Serializable
internal sealed interface AppSettingsDialogConfig {
@Serializable
data class ThemeModeSelector(val selectedModeIndex: Int) : AppSettingsDialogConfig
}

View file

@ -1,11 +1,18 @@
package com.tangem.tap.features.details.ui.appsettings.model
import androidx.compose.runtime.Stable
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.appcurrency.model.AppCurrency
@ -13,113 +20,138 @@ import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.features.details.redux.AppSetting
import com.tangem.tap.features.details.redux.AppSettingsState
import com.tangem.tap.features.details.redux.DetailsAction
import com.tangem.tap.features.details.redux.DetailsState
import com.tangem.tap.features.details.ui.appsettings.AppSettingsDialogsFactory
import com.tangem.tap.features.details.ui.appsettings.AppSettingsItemsFactory
import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState
import com.tangem.tap.features.details.ui.appsettings.analytics.AppSettingsItemsAnalyticsSender
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.extensions.addIf
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.rekotlin.StoreSubscriber
import javax.inject.Inject
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
@Stable
@ModelScoped
internal class AppSettingsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val appCurrencyRepository: AppCurrencyRepository,
appCurrencyRepository: AppCurrencyRepository,
private val walletsRepository: WalletsRepository,
private val userWalletsListRepository: UserWalletsListRepository,
private val balanceHidingRepository: BalanceHidingRepository,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appThemeModeRepository: AppThemeModeRepository,
private val appSettingsItemsAnalyticsSender: AppSettingsItemsAnalyticsSender,
private val tangemSdkManager: TangemSdkManager,
private val settingsManager: SettingsManager,
private val settingsRepository: SettingsRepository,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val tangemHotSdk: TangemHotSdk,
private val router: Router,
private val uiMessageSender: UiMessageSender,
) : Model(), StoreSubscriber<DetailsState> {
) : Model() {
private val itemsFactory = AppSettingsItemsFactory()
private val dialogsFactory = AppSettingsDialogsFactory()
private val appCurrencyUpdatesJobHolder = JobHolder()
val dialogNavigation: SlotNavigation<AppSettingsDialogConfig> = SlotNavigation()
private val _uiState: MutableStateFlow<AppSettingsScreenState> = MutableStateFlow(
value = AppSettingsScreenState.Loading,
)
val uiState: StateFlow<AppSettingsScreenState> = _uiState
private val localState = MutableStateFlow(LocalState())
private val biometricsStatusJobHolder = JobHolder()
val uiState: StateFlow<AppSettingsScreenState>
field = MutableStateFlow<AppSettingsScreenState>(value = AppSettingsScreenState.Loading)
init {
bootstrapAppCurrencyUpdates()
bootstrapBiometricsUpdates()
bootstrapLocalState()
combine(
flow = appCurrencyRepository.getSelectedAppCurrency().distinctUntilChanged(),
flow2 = appThemeModeRepository.getAppThemeMode(),
flow3 = balanceHidingRepository.getBalanceHidingSettingsFlow(),
flow4 = localState,
) { currency, themeMode, hidingSettings, local ->
AppSettingsState(
appCurrency = currency,
themeMode = themeMode,
isHidingEnabled = hidingSettings.isHidingEnabledInSettings,
local = local,
)
}
.onEach { state ->
val items = buildItems(state)
uiState.update { prevState ->
when (prevState) {
is AppSettingsScreenState.Content -> prevState.copy(items = items)
is AppSettingsScreenState.Loading -> AppSettingsScreenState.Content(items = items)
}
}
}
.launchIn(modelScope)
subscribeToStoreChanges()
sendItemsAnalytics()
}
override fun newState(state: DetailsState) {
val items = buildItems(state.appSettingsState)
_uiState.update { prevState ->
when (prevState) {
is AppSettingsScreenState.Content -> prevState.copy(
items = items,
)
is AppSettingsScreenState.Loading -> AppSettingsScreenState.Content(
items = items,
dialog = null,
)
}
}
}
fun onResume() {
store.dispatch(DetailsAction.AppSettings.CheckBiometricsStatus(modelScope))
observeBiometricsStatusChanges()
}
override fun onDestroy() {
super.onDestroy()
store.unsubscribe(subscriber = this)
private fun observeBiometricsStatusChanges() {
flow {
do {
val isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull()
if (isEnrollBiometricsNeeded != null) {
emit(isEnrollBiometricsNeeded)
}
delay(timeMillis = 200)
} while (true)
}
.flowOn(dispatchers.default)
.distinctUntilChanged()
.onEach { isEnrollBiometricsNeeded ->
localState.update { it.copy(isEnrollBiometricsNeeded = isEnrollBiometricsNeeded) }
}
.launchIn(modelScope)
.saveIn(biometricsStatusJobHolder)
}
private fun buildItems(state: AppSettingsState): ImmutableList<AppSettingsScreenState.Item> {
val items = buildList {
addIf(
condition = state.needEnrollBiometrics,
condition = state.local.isEnrollBiometricsNeeded,
element = itemsFactory.createEnrollBiometricsCard(onClick = ::enrollBiometrics),
)
add(
itemsFactory.createSelectAppCurrencyButton(
currentAppCurrencyName = state.selectedAppCurrency.name,
currentAppCurrencyName = state.appCurrency.name,
onClick = ::showAppCurrencySelector,
),
)
val canUseBiometrics =
!state.needEnrollBiometrics && !state.isInProgress && state.hasSecuredWallets
val canUseBiometrics = with(state.local) {
!isEnrollBiometricsNeeded && !isInProgress && hasSecuredWallets
}
add(
itemsFactory.createUseBiometricsSwitch(
isChecked = state.useBiometricAuthentication,
isChecked = state.local.isBiometricAuthenticationUsed,
isEnabled = canUseBiometrics,
onCheckedChange = ::onBiometricAuthenticationToggled,
onDisabledClick = ::onBiometricAuthenticationDisabledClicked,
@ -128,8 +160,8 @@ internal class AppSettingsModel @Inject constructor(
add(
itemsFactory.createRequireAccessCodeSwitch(
isChecked = state.requireAccessCode || !state.useBiometricAuthentication,
isEnabled = canUseBiometrics && state.useBiometricAuthentication,
isChecked = state.local.isAccessCodeRequired || !state.local.isBiometricAuthenticationUsed,
isEnabled = canUseBiometrics && state.local.isBiometricAuthenticationUsed,
onCheckedChange = ::onRequireAccessCodeToggled,
),
)
@ -144,8 +176,8 @@ internal class AppSettingsModel @Inject constructor(
add(
itemsFactory.createSelectThemeModeButton(
currentThemeMode = state.selectedThemeMode,
onClick = { showThemeModeSelector(state.selectedThemeMode) },
currentThemeMode = state.themeMode,
onClick = { showThemeModeSelector(state.themeMode) },
),
)
}
@ -154,31 +186,35 @@ internal class AppSettingsModel @Inject constructor(
}
private fun enrollBiometrics() {
store.dispatchOnMain(DetailsAction.AppSettings.EnrollBiometrics)
analyticsEventHandler.send(Settings.AppSettings.ButtonEnableBiometricAuthentication())
settingsManager.openBiometricSettings()
}
fun onBackClick() {
router.pop()
}
private fun showAppCurrencySelector() {
store.dispatchNavigationAction { push(AppRoute.AppCurrencySelector) }
router.push(AppRoute.AppCurrencySelector)
}
private fun showThemeModeSelector(selectedMode: AppThemeMode) {
updateContentState {
copy(
dialog = dialogsFactory.createThemeModeSelectorDialog(
selectedModeIndex = selectedMode.ordinal,
onSelect = { mode ->
analyticsEventHandler.send(
event = Settings.AppSettings.ThemeSwitched(
theme = AnalyticsParam.AppTheme.fromAppThemeMode(mode),
),
)
store.dispatchOnMain(DetailsAction.AppSettings.ChangeAppThemeMode(mode))
dismissDialog()
},
onDismiss = ::dismissDialog,
),
)
}
dialogNavigation.activate(AppSettingsDialogConfig.ThemeModeSelector(selectedMode.ordinal))
}
fun onThemeModeSelected(index: Int) {
val mode = AppThemeMode.available[index]
analyticsEventHandler.send(
event = Settings.AppSettings.ThemeSwitched(
theme = AnalyticsParam.AppTheme.fromAppThemeMode(mode),
),
)
changeAppThemeMode(mode)
dialogNavigation.dismiss()
}
fun dismissDialog() {
dialogNavigation.dismiss()
}
private fun onBiometricAuthenticationToggled(isChecked: Boolean) {
@ -186,19 +222,13 @@ internal class AppSettingsModel @Inject constructor(
// val param = AnalyticsParam.OnOffState(isChecked)
// analyticsEventHandler.send(Settings.AppSettings.BiometricAuthenticationChanged(param))
if (isChecked) {
onSettingsToggled(AppSetting.BiometricAuthentication, enable = true)
toggleBiometricsAuthentication(enable = true)
} else {
updateContentState {
copy(
dialog = dialogsFactory.createDisableBiometricAuthenticationAlert(
onDisable = {
onSettingsToggled(AppSetting.BiometricAuthentication, enable = false)
dismissDialog()
},
onDismiss = ::dismissDialog,
),
)
}
uiMessageSender.send(
dialogsFactory.createDisableBiometricAuthenticationAlert(
onDisable = { toggleBiometricsAuthentication(enable = false) },
),
)
}
}
@ -210,74 +240,136 @@ internal class AppSettingsModel @Inject constructor(
// TODO : Uncomment and implement analytics event when ready
// val param = AnalyticsParam.OnOffState(isChecked)
// analyticsEventHandler.send(Settings.AppSettings.RequireAccessCodeChanged(param))
updateContentState {
copy(
dialog = if (isChecked) {
dialogsFactory.createEnableRequireAccessCodeAlert(
onEnable = {
onSettingsToggled(AppSetting.RequireAccessCode, enable = true)
dismissDialog()
},
onDismiss = ::dismissDialog,
)
} else {
dialogsFactory.createDisableRequireAccessCodeAlert(
onDisable = {
onSettingsToggled(AppSetting.RequireAccessCode, enable = false)
dismissDialog()
},
onDismiss = ::dismissDialog,
)
},
if (isChecked) {
uiMessageSender.send(
dialogsFactory.createEnableRequireAccessCodeAlert(
onEnable = { toggleRequireAccessCode(enable = true) },
),
)
} else {
uiMessageSender.send(
dialogsFactory.createDisableRequireAccessCodeAlert(
onDisable = { toggleRequireAccessCode(enable = false) },
),
)
}
}
private fun onSettingsToggled(setting: AppSetting, enable: Boolean) {
store.dispatch(DetailsAction.AppSettings.SwitchPrivacySetting(enable = enable, setting = setting))
private fun toggleBiometricsAuthentication(enable: Boolean) {
localState.update { it.copy(isBiometricAuthenticationUsed = enable, isInProgress = true) }
modelScope.launch {
// Nothing to change
if (walletsRepository.useBiometricAuthentication() == enable) {
localState.update { it.copy(isInProgress = false) }
return@launch
}
if (enable) {
setBiometricLockForAllWallets()
} else {
removeAllBiometricData()
walletsRepository.setRequireAccessCode(value = true)
localState.update { it.copy(isAccessCodeRequired = true) }
}
walletsRepository.setUseBiometricAuthentication(value = enable)
localState.update { it.copy(isInProgress = false) }
}
}
private fun toggleRequireAccessCode(enable: Boolean) {
localState.update { it.copy(isAccessCodeRequired = enable, isInProgress = true) }
modelScope.launch {
// Nothing to change
if (walletsRepository.requireAccessCode() == enable) {
localState.update { it.copy(isInProgress = false) }
return@launch
}
if (enable) {
removeAllBiometricSingData(userWalletsListRepository.userWalletsSync())
}
walletsRepository.setRequireAccessCode(value = enable)
localState.update { it.copy(isInProgress = false) }
}
}
private suspend fun setBiometricLockForAllWallets() {
val userWallets = userWalletsListRepository.userWalletsSync()
userWallets.forEach { wallet ->
userWalletsListRepository.setLock(
userWalletId = wallet.walletId,
lockMethod = UserWalletsListRepository.LockMethod.Biometric,
changeUnsecured = false,
)
}
}
private suspend fun removeAllBiometricData() {
val userWallets = userWalletsListRepository.userWalletsSync()
userWallets.forEach {
userWalletsListRepository.removeBiometricLock(it.walletId)
}
removeAllBiometricSingData(userWallets)
}
private suspend fun removeAllBiometricSingData(userWallets: List<UserWallet>) {
deleteSavedAccessCodes()
userWallets.forEach { wallet ->
if (wallet is UserWallet.Hot) {
userWalletsListRepository.saveWithoutLock(
userWallet = wallet.copy(
hotWalletId = tangemHotSdk.removeBiometryAuthIfPresented(wallet.hotWalletId),
),
)
}
}
}
private suspend fun deleteSavedAccessCodes() {
tangemSdkManager.clearSavedUserCodes()
.doOnSuccess {
analyticsEventHandler.send(
Settings.AppSettings.SaveAccessCodeSwitcherChanged(AnalyticsParam.OnOffState.Off),
)
settingsRepository.setShouldSaveAccessCodes(value = false)
cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = false)
}
.doOnFailure { error ->
TangemLogger.e("Unable to delete saved access codes", error)
}
}
private fun onFlipToHideBalanceToggled(enable: Boolean) {
val param = AnalyticsParam.OnOffState(enable)
analyticsEventHandler.send(Settings.AppSettings.HideBalanceChanged(param))
store.dispatch(DetailsAction.AppSettings.ChangeBalanceHiding(shouldHideBalance = enable))
modelScope.launch {
val settings = balanceHidingRepository.getBalanceHidingSettings().copy(
isHidingEnabledInSettings = enable,
isBalanceHidden = false,
)
balanceHidingRepository.storeBalanceHidingSettings(settings)
}
}
private fun dismissDialog() {
updateContentState { copy(dialog = null) }
private fun changeAppThemeMode(mode: AppThemeMode) {
modelScope.launch {
appThemeModeRepository.changeAppThemeMode(mode)
}
}
private fun bootstrapAppCurrencyUpdates() {
appCurrencyRepository
.getSelectedAppCurrency()
.onEach { appCurrency ->
if (appCurrency.code == store.state.globalState.appCurrency.code) return@onEach
store.dispatchWithMain(DetailsAction.AppSettings.ChangeAppCurrency(appCurrency))
}
.launchIn(scope)
.saveIn(appCurrencyUpdatesJobHolder)
}
private fun bootstrapBiometricsUpdates() = modelScope.launch {
val state = AppSettingsState(
useBiometricAuthentication = walletsRepository.useBiometricAuthentication(),
requireAccessCode = walletsRepository.requireAccessCode(),
isHidingEnabled = balanceHidingRepository.getBalanceHidingSettings().isHidingEnabledInSettings,
selectedAppCurrency = appCurrencyRepository.getSelectedAppCurrency().firstOrNull() ?: AppCurrency.Default,
selectedThemeMode = appThemeModeRepository.getAppThemeMode().firstOrNull() ?: AppThemeMode.DEFAULT,
hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(),
)
store.dispatchWithMain(DetailsAction.AppSettings.Prepare(state))
}
private fun subscribeToStoreChanges() {
store.subscribe(subscriber = this) { state ->
state.skipRepeats { oldState, newState ->
oldState.detailsState == newState.detailsState
}.select { it.detailsState }
private fun bootstrapLocalState() = modelScope.launch {
localState.update { state ->
state.copy(
hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(),
isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true,
isBiometricAuthenticationUsed = walletsRepository.useBiometricAuthentication(),
isAccessCodeRequired = walletsRepository.requireAccessCode(),
)
}
}
@ -286,15 +378,21 @@ internal class AppSettingsModel @Inject constructor(
.filterIsInstance<AppSettingsScreenState.Content>()
.distinctUntilChangedBy(AppSettingsScreenState.Content::items)
.onEach { appSettingsItemsAnalyticsSender.send(it.items) }
.launchIn(scope)
.launchIn(modelScope)
}
private fun updateContentState(block: AppSettingsScreenState.Content.() -> AppSettingsScreenState.Content) {
_uiState.update { prevState ->
when (prevState) {
is AppSettingsScreenState.Content -> block(prevState)
is AppSettingsScreenState.Loading -> prevState
}
}
}
private data class LocalState(
val hasSecuredWallets: Boolean = false,
val isEnrollBiometricsNeeded: Boolean = false,
val isBiometricAuthenticationUsed: Boolean = false,
val isAccessCodeRequired: Boolean = false,
val isInProgress: Boolean = false,
)
private data class AppSettingsState(
val themeMode: AppThemeMode = AppThemeMode.DEFAULT,
val isHidingEnabled: Boolean = false,
val appCurrency: AppCurrency = AppCurrency.Default,
val local: LocalState = LocalState(),
)
}

View file

@ -188,7 +188,7 @@ internal class DefaultRampManager(
private fun CryptoCurrency.findAssetPredicate(assetId: ExpressAsset.ID): Boolean {
val currencyAssedId = ExpressAsset.ID(
networkId = this.network.backendId,
networkId = this.network.rawId,
contractAddress = (this as? CryptoCurrency.Token)?.contractAddress,
)

View file

@ -138,6 +138,7 @@ internal class ChildFactory @Inject constructor(
AppRoute.ManageTokens.Source.SETTINGS -> ManageTokensSource.SETTINGS
AppRoute.ManageTokens.Source.STORIES -> ManageTokensSource.STORIES
AppRoute.ManageTokens.Source.ACCOUNT -> ManageTokensSource.ACCOUNT
AppRoute.ManageTokens.Source.WALLET -> ManageTokensSource.WALLET
}
val mode = route.accountId?.let { ManageTokensMode.Account(it) } ?: ManageTokensMode.None