Updated on 2026-08-14
This commit is contained in:
commit
f3dfb262c2
527 changed files with 12898 additions and 3301 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class FinalizeTwinTask(
|
|||
visaCardScanHandler = null,
|
||||
visaCoroutineScope = null,
|
||||
shouldCheckIsAlreadyActivated = false,
|
||||
isDynamicAddressesEnabled = false,
|
||||
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
|
||||
onboardingV2FeatureToggles = null,
|
||||
).run(session, callback)
|
||||
is CompletionResult.Failure ->
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.tangem.tap.features.details.redux
|
||||
|
||||
enum class SecurityOption { LongTap, PassCode, AccessCode }
|
||||
|
|
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue