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

@ -161,7 +161,7 @@ dependencies {
implementation(projects.domain.hotWallet)
implementation(projects.domain.news)
implementation(projects.domain.earn)
implementation(projects.domain.tokensync)
implementation(projects.domain.assetsdiscovery)
implementation(projects.domain.search)
implementation(projects.common)
@ -192,7 +192,7 @@ dependencies {
implementation(projects.data.common)
implementation(projects.data.settings)
implementation(projects.data.tokens)
implementation(projects.data.tokensync)
implementation(projects.data.assetsdiscovery)
implementation(projects.data.txhistory)
implementation(projects.data.wallets)
implementation(projects.data.analytics)
@ -274,6 +274,8 @@ dependencies {
implementation(projects.features.nft.impl)
implementation(projects.features.walletconnect.api)
implementation(projects.features.walletconnect.impl)
implementation(projects.features.commonFeatures.api)
implementation(projects.features.commonFeatures.impl)
implementation(projects.features.usedesk.api)
implementation(projects.features.usedesk.impl)
implementation(projects.features.hotWallet.api)

View file

@ -8,6 +8,8 @@ import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.intent.Intents
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import com.kaspersky.components.alluresupport.interceptors.step.ScreenshotStepInterceptor
import com.kaspersky.components.alluresupport.withForcedAllureSupport
import com.kaspersky.components.composesupport.config.addComposeSupport
@ -20,13 +22,14 @@ import com.tangem.common.rules.ApiEnvironmentRule
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.PromoId
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.tap.MainActivity
import dagger.hilt.android.testing.HiltAndroidRule
import io.qameta.allure.kotlin.Allure
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
@ -58,6 +61,12 @@ abstract class BaseTestCase : TestCase(
@Inject
lateinit var promoRepository: PromoRepository
@Inject
lateinit var walletManagersStore: WalletManagersStore
@Inject
lateinit var getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase
private val hiltRule = HiltAndroidRule(this)
private val apiEnvironmentRule = ApiEnvironmentRule()
private val permissionRule = GrantPermissionRule.grant(
@ -72,7 +81,11 @@ abstract class BaseTestCase : TestCase(
private val semanticTreePrinterRule = object : TestWatcher() {
override fun failed(e: Throwable?, description: Description?) {
runCatching { printAllRoots() }
runCatching {
runBlocking {
withTimeoutOrNull(SEMANTIC_TREE_PRINT_TIMEOUT_MS) { printAllRoots() }
}
}
}
}
@ -174,5 +187,6 @@ abstract class BaseTestCase : TestCase(
private companion object {
const val WIREMOCK_BASE_URL_ARG = "wiremockBaseUrl"
const val SEMANTIC_TREE_PRINT_TIMEOUT_MS = 5_000L
}
}

View file

@ -35,6 +35,7 @@ object TestConstants {
const val WAIT_UNTIL_TIMEOUT = 20_000L
const val WAIT_UNTIL_TIMEOUT_LONG = 30_000L
const val WAIT_UNTIL_TIMEOUT_VERY_LONG = 60_000L
const val MARKETS_MAIN_NETWORK_SUFFIX = "MAIN"
@ -43,4 +44,14 @@ object TestConstants {
const val USER_TOKENS_API_SCENARIO = "user_tokens_api"
const val QUOTES_API_SCENARIO = "quotes_api"
const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk"
const val SEED_PHRASE_15 = "genuine try deer upset connect sausage diary rule price shallow fit faculty leopard " +
"hawk when"
const val SEED_PHRASE_18 = "crush idle include refuse expose kiss slot budget uphold when dinner certain holiday " +
"slow word armor butter suffer"
const val SEED_PHRASE_21 = "employ space oval venue wash clog zebra cover icon wash assist word debris inform " +
"cable meadow add game meat rigid pride"
const val SEED_PHRASE_24 = "force visit fresh brown razor target ill scissors figure cave feel genre cargo category " +
"bread much nature basic fun iron benefit egg error prosper"
}

View file

@ -0,0 +1,88 @@
package com.tangem.common.utils
import com.tangem.utils.logging.TangemLogger
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertTrue
/**
* Compares addresses from the app (clipboard JSON) with reference addresses from the QA tools API.
*
* Both JSON arrays contain objects with fields: blockchain, derivationPath, token (nullable), addresses (array).
* Comparison normalizes and sorts both arrays before diffing.
*/
object AddressComparisonHelper {
fun compareAddresses(appJson: String, apiJson: String) {
val appEntries = parseAndNormalize(appJson)
val apiEntries = parseAndNormalize(apiJson)
val missingInApp = apiEntries - appEntries.toSet()
val extraInApp = appEntries - apiEntries.toSet()
if (missingInApp.isEmpty() && extraInApp.isEmpty()) {
TangemLogger.i("Address comparison passed: ${appEntries.size} entries match")
return
}
val report = buildString {
appendLine("Address comparison FAILED")
if (missingInApp.isNotEmpty()) {
appendLine("\nMissing in app (expected from API but not found):")
missingInApp.forEach { appendLine(" - $it") }
}
if (extraInApp.isNotEmpty()) {
appendLine("\nExtra in app (found in app but not in API):")
extraInApp.forEach { appendLine(" - $it") }
}
appendLine("\nApp entries: ${appEntries.size}, API entries: ${apiEntries.size}")
}
TangemLogger.e(report)
assertTrue(report, false)
}
private fun parseAndNormalize(json: String): List<AddressEntry> {
val array = JSONArray(json)
val entries = mutableListOf<AddressEntry>()
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
entries.add(
AddressEntry(
blockchain = normalizeBlockchainName(obj.getString("blockchain")),
derivationPath = obj.getString("derivationPath").trim(),
token = obj.optString("token", null)?.trim()?.lowercase(),
addresses = parseAddresses(obj).sorted(),
),
)
}
return entries.sortedWith(
compareBy<AddressEntry> { it.blockchain }
.thenBy { it.derivationPath }
.thenBy { it.token },
)
}
private val blockchainNameOverrides = mapOf(
"chia network" to "chia",
)
private fun normalizeBlockchainName(name: String): String {
val normalized = name.trim().lowercase()
return blockchainNameOverrides[normalized] ?: normalized
}
private fun parseAddresses(obj: JSONObject): List<String> {
val addressesArray = obj.getJSONArray("addresses")
return (0 until addressesArray.length()).map { addressesArray.getString(it) }
}
private data class AddressEntry(
val blockchain: String,
val derivationPath: String,
val token: String?,
val addresses: List<String>,
)
}

View file

@ -16,10 +16,10 @@ fun getWcUri(
TangemLogger.i("Getting WC URI for network: $network")
val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) // Таймаут подключения
.readTimeout(60, TimeUnit.SECONDS) // Таймаут чтения ответа
.writeTimeout(30, TimeUnit.SECONDS) // Таймаут записи
.callTimeout(90, TimeUnit.SECONDS) // Общий таймаут запроса
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(90, TimeUnit.SECONDS)
.build()
val request = Request.Builder()
@ -58,6 +58,59 @@ fun getWcUri(
}
}
fun getAddressesFromApi(
seedKey: String,
baseUrl: String = "[REDACTED_ENV_URL]",
): String? {
TangemLogger.i("Getting addresses for seed key: $seedKey")
val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.callTimeout(90, TimeUnit.SECONDS)
.build()
val request = Request.Builder()
.url("$baseUrl/addresses")
.get()
.build()
return try {
client.newCall(request).execute().use { response ->
TangemLogger.i("Response code: ${response.code}")
if (response.isSuccessful) {
val body = response.body?.string() ?: ""
val contentType = response.header("Content-Type") ?: ""
if (!contentType.contains("application/json") && !body.trimStart().startsWith("{")) {
TangemLogger.e("Unexpected response (not JSON), Content-Type: $contentType, body: $body")
return null
}
val jsonObject = JSONObject(body)
val data = jsonObject.optJSONObject("data") ?: jsonObject
val seedData = data.optJSONArray(seedKey)
if (seedData != null) {
TangemLogger.i("Got addresses for $seedKey: ${seedData.length()} entries")
seedData.toString()
} else {
TangemLogger.e("No data found for seed key: $seedKey")
null
}
} else {
val errorBody = response.body?.string() ?: "No error body"
TangemLogger.e("Request failed: ${response.code}, body: $errorBody")
null
}
}
} catch (e: Exception) {
TangemLogger.e("Error getting addresses", e)
null
}
}
fun checkServiceHealth(
baseUrl: String = "[REDACTED_ENV_URL]"
): String? {

View file

@ -0,0 +1,122 @@
package com.tangem.scenarios
import android.view.KeyEvent
import androidx.test.core.app.ApplicationProvider
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
import com.tangem.common.utils.AddressComparisonHelper
import com.tangem.common.utils.getClipboardText
import com.tangem.screens.onMainScreen
import com.tangem.screens.onTesterMenuScreen
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.logging.TangemLogger
import io.qameta.allure.kotlin.Allure.step
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertNotNull
private const val WALLET_MANAGERS_SETTLE_MS = 5_000L
private const val WALLET_MANAGERS_POLL_INTERVAL_MS = 500L
fun BaseTestCase.verifyAddresses(seedPhrase: String, apiAddressesJson: String) {
var appAddressesJson: String? = null
step("Open 'Main Screen' with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
step("Assert wallet balance = '$DASH_SIGN'") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
runCatching { onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } }.isSuccess
}
}
step("Assert 'Organize tokens' button is enabled") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
runCatching { onMainScreen { organizeTokensButton().assertIsEnabled() } }.isSuccess
}
}
step("Wait for all wallet managers to initialize") {
awaitWalletManagersStabilized()
}
step("Open tester menu") {
openTesterMenu()
}
step("Click on 'Addresses info' button") {
onTesterMenuScreen { addressesInfoButton.performClick() }
}
step("Click on 'JSON' tab") {
onTesterMenuScreen { jsonTab.performClick() }
}
step("Click on 'Copy' button") {
onTesterMenuScreen { copyButton.performClick() }
}
step("Get addresses JSON from clipboard") {
appAddressesJson = getClipboardText(ApplicationProvider.getApplicationContext())
assertNotNull("Clipboard is empty after copying addresses", appAddressesJson)
}
step("Compare app addresses with API reference") {
AddressComparisonHelper.compareAddresses(
appJson = requireNotNull(appAddressesJson),
apiJson = apiAddressesJson,
)
}
}
private const val TESTER_MENU_MAX_ATTEMPTS = 3
/**
* Presses 'Volume Down' twice to open tester menu.
* Retries up to [TESTER_MENU_MAX_ATTEMPTS] times if the menu doesn't appear.
*/
private fun BaseTestCase.openTesterMenu() {
repeat(TESTER_MENU_MAX_ATTEMPTS) { attempt ->
waitForIdle()
device.uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN)
device.uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN)
val opened = runCatching {
onTesterMenuScreen { addressesInfoButton.assertIsDisplayed() }
}.isSuccess
if (opened) {
TangemLogger.i("Tester menu opened on attempt ${attempt + 1}")
return
}
TangemLogger.w("Tester menu not opened on attempt ${attempt + 1}, retrying...")
}
error("Failed to open tester menu after $TESTER_MENU_MAX_ATTEMPTS attempts")
}
/**
* Polls [walletManagersStore] until the wallet manager count stops growing for [WALLET_MANAGERS_SETTLE_MS].
*
* Uses [getAllSync] with a polling interval instead of Flow, because the Flow only emits on changes
* if the count stabilizes, there would be no new emission to check the settle timeout against.
*/
private fun BaseTestCase.awaitWalletManagersStabilized() {
val walletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId
?: error("No selected wallet found")
var lastSize = -1
var stableStart = System.currentTimeMillis()
runBlocking {
withTimeout(WAIT_UNTIL_TIMEOUT_VERY_LONG) {
while (true) {
val currentSize = walletManagersStore.getAllSync(walletId).size
val now = System.currentTimeMillis()
if (currentSize != lastSize) {
TangemLogger.i("Wallet managers count: $currentSize (was $lastSize)")
lastSize = currentSize
stableStart = now
} else if (now - stableStart >= WALLET_MANAGERS_SETTLE_MS) {
TangemLogger.i("Wallet managers stabilized at $currentSize entries")
return@withTimeout
}
delay(WALLET_MANAGERS_POLL_INTERVAL_MS)
}
}
}
}

View file

@ -15,11 +15,10 @@ fun BaseTestCase.scanCard(
mockContent: MockContent? = null,
isTwinsCard: Boolean = false,
) {
if (productType != null) {
MockProvider.setMocks(productType)
}
if (mockContent != null) {
MockProvider.setMocks(mockContent)
when {
mockContent != null -> MockProvider.setMocks(mockContent)
productType != null -> MockProvider.setMocks(productType)
else -> MockProvider.setMocks(ProductType.Wallet)
}
step("Click on 'Accept' button") {
onDisclaimerScreen { acceptButton.clickWithAssertion() }
@ -60,6 +59,57 @@ fun BaseTestCase.openMainScreen(
}
}
fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String) {
step("Click on 'Accept' button") {
onDisclaimerScreen { acceptButton.clickWithAssertion() }
}
step("Click on 'Get started' button") {
onStoriesScreen { getStartedButton.clickWithAssertion() }
}
step("Click on 'Start with Mobile Wallet' button") {
onCreateWalletStartScreen { startWithMobileWalletButton.performClick() }
}
step("Click on 'Import existing wallet' button") {
onCreateMobileWalletScreen { importExistingWalletButton.performClick() }
}
step("Click on 'Phrase text field'") {
onImportWalletScreen { phraseTextField.performClick() }
}
step("Type seed phrase in 'Phrase text field'") {
onImportWalletScreen { phraseTextField.performTextReplacement(seedPhrase) }
}
step("Click on 'Import' button") {
onImportWalletScreen {
importButton.assertIsEnabled()
importButton.performClick()
}
}
step("Click on 'Continue' button") {
onImportWalletScreen {
continueButton.assertIsEnabled()
continueButton.performClick()
}
}
step("Click on 'Skip' button") {
onImportWalletScreen { skipButton.performClick() }
}
step("Click on 'Skip anyway' dialog button") {
onDialog { skipAnywayButton.performClick() }
}
step("Click on 'Finish' button") {
onImportWalletScreen {
finishButton.assertIsEnabled()
finishButton.performClick()
}
}
step("Assert 'Main' screen is displayed") {
onMainScreen { screenContainer.assertIsDisplayed() }
}
step("Dismiss Market Tooltip by clicking close button") {
onMarketsTooltipScreen { closeButton.clickWithAssertion() }
}
}
fun BaseTestCase.synchronizeAddresses(
balance: String? = null,
isBalanceAvailable: Boolean = true

View file

@ -0,0 +1,23 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.core.ui.test.BaseButtonTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class CreateMobileWalletPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<CreateMobileWalletPageObject>(semanticsProvider = semanticsProvider) {
val importExistingWalletButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.hw_import_existing_wallet))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onCreateMobileWalletScreen(function: CreateMobileWalletPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -17,6 +17,11 @@ class CreateWalletStartPageObject(semanticsProvider: SemanticsNodeInteractionsPr
hasText(getResourceString(OnboardingImplR.string.welcome_unlock_card))
useUnmergedTree = true
}
val startWithMobileWalletButton: KNode = child {
hasText(getResourceString(OnboardingImplR.string.welcome_create_wallet_mobile_title))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onCreateWalletStartScreen(function: CreateWalletStartPageObject.() -> Unit) =

View file

@ -64,6 +64,11 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_change))
}
val skipAnywayButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.access_code_alert_skip_ok))
}
}
internal fun BaseTestCase.onDialog(function: DialogPageObject.() -> Unit) =

View file

@ -0,0 +1,54 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.ImportWalletScreenTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class ImportWalletPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ImportWalletPageObject>(semanticsProvider = semanticsProvider) {
val phraseTextField: KNode = child {
hasTestTag(ImportWalletScreenTestTags.PHRASE_TEXT_FIELD)
useUnmergedTree = true
}
val passphraseTextField: KNode = child {
hasTestTag(ImportWalletScreenTestTags.PASSPHRASE_TEXT_FIELD)
useUnmergedTree = true
}
val importButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasAnyChild(withText(getResourceString(R.string.common_import)))
useUnmergedTree = true
}
val continueButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasAnyChild(withText(getResourceString(R.string.common_continue)))
useUnmergedTree = true
}
val skipButton: KNode = child {
hasTestTag(TopAppBarTestTags.MORE_BUTTON)
hasText(getResourceString(R.string.common_skip))
useUnmergedTree = true
}
val finishButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasAnyChild(withText(getResourceString(R.string.common_finish)))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onImportWalletScreen(function: ImportWalletPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,39 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.feature.tester.impl.R as TesterImplR
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class TesterMenuPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TesterMenuPageObject>(semanticsProvider = semanticsProvider) {
val backButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
useUnmergedTree = true
}
val addressesInfoButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(TesterImplR.string.addresses_info))
useUnmergedTree = true
}
val jsonTab: KNode = child {
hasText("JSON")
useUnmergedTree = true
}
val copyButton: KNode = child {
hasContentDescription("Copy")
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTesterMenuScreen(function: TesterMenuPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,86 @@
package com.tangem.tests.hotWallet
import com.tangem.common.BaseTestCase
import com.tangem.common.annotations.ApiEnv
import com.tangem.common.annotations.ApiEnvConfig
import com.tangem.common.constants.TestConstants.SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.SEED_PHRASE_15
import com.tangem.common.constants.TestConstants.SEED_PHRASE_18
import com.tangem.common.constants.TestConstants.SEED_PHRASE_21
import com.tangem.common.constants.TestConstants.SEED_PHRASE_24
import com.tangem.common.utils.checkServiceHealth
import com.tangem.common.utils.getAddressesFromApi
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.scenarios.verifyAddresses
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Assert.assertNotNull
import org.junit.Test
@HiltAndroidTest
class AddressesTest : BaseTestCase() {
private var apiAddressesJson: String? = null
private fun setupAddressTestHooks(seedKey: String) = setupHooks(
additionalBeforeAppLaunchSection = {
val status = checkServiceHealth()
assertNotNull("QA tools service is unreachable ([REDACTED_ENV_URL]", status)
apiAddressesJson = getAddressesFromApi(seedKey)
assertNotNull("Failed to fetch reference addresses for '$seedKey'", apiAddressesJson)
},
)
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@AllureId("1792")
@DisplayName("Hot wallet: auto derivation addresses for seed 12")
@Test
fun seed12AddressesTest() {
setupAddressTestHooks("twelve").run {
verifyAddresses(SEED_PHRASE_12, requireNotNull(apiAddressesJson))
}
}
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@AllureId("5106")
@DisplayName("Hot wallet: auto derivation addresses for seed 15")
@Test
fun seed15AddressesTest() {
setupAddressTestHooks("fifteen").run {
verifyAddresses(SEED_PHRASE_15, requireNotNull(apiAddressesJson))
}
}
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@AllureId("5107")
@DisplayName("Hot wallet: auto derivation addresses for seed 18")
@Test
fun seed18AddressesTest() {
setupAddressTestHooks("eighteen").run {
verifyAddresses(SEED_PHRASE_18, requireNotNull(apiAddressesJson))
}
}
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@AllureId("5108")
@DisplayName("Hot wallet: auto derivation addresses for seed 21")
@Test
fun seed21AddressesTest() {
setupAddressTestHooks("twenty_one").run {
verifyAddresses(SEED_PHRASE_21, requireNotNull(apiAddressesJson))
}
}
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@AllureId("5109")
@DisplayName("Hot wallet: auto derivation addresses for seed 24")
@Test
fun seed24AddressesTest() {
setupAddressTestHooks("twenty_four").run {
verifyAddresses(SEED_PHRASE_24, requireNotNull(apiAddressesJson))
}
}
}

View file

@ -0,0 +1,151 @@
package com.tangem.tests.send.warnings
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.CARDANO_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.scenarios.checkSendWarning
import com.tangem.scenarios.openSendScreen
import com.tangem.screens.onSendAddressScreen
import com.tangem.screens.onSendScreen
import com.tangem.wallet.R
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class CardanoWarningsTest : BaseTestCase() {
private val tokenName = "Cardano"
private val minAmount = "ADA 1.00"
private val invalidAmountTitle = getResourceString(R.string.send_notification_invalid_amount_title)
private val invalidAmountMessage =
getResourceString(R.string.send_notification_invalid_minimum_amount_text, minAmount, minAmount)
@AllureId("4204")
@DisplayName("Warnings: check warning, when remains less than 1 ADA")
@Test
fun afterTransactionRemainsLessThanMinimumAmountTest() {
val sendAmount = "19"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$sendAmount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(sendAmount)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(CARDANO_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount' warning is displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage,
isDisplayed = true
)
}
}
}
@AllureId("4207")
@DisplayName("Warnings: check warning, when amount more than 1 ADA")
@Test
fun transactionAmountMoreThanOneTest() {
val sendAmount = "2.5"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$sendAmount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(sendAmount)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(CARDANO_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount' warning is not displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage,
isDisplayed = false
)
}
}
}
@AllureId("4210")
@DisplayName("Warnings: check warning, when remains more than 1 ADA")
@Test
fun afterTransactionRemainsMoreThanMinimumAmountTest() {
val sendAmount = "18"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$sendAmount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(sendAmount)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(CARDANO_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount' warning is not displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage,
isDisplayed = false
)
}
}
}
}

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

View file

@ -2,5 +2,6 @@
<resources>
<string name="tangem_app_name" translatable="false">Tangem</string>
<string name="mock_card_picker_title" translatable="false">Select Mock Card</string>
</resources>