Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-20 15:31:57 +03:00
commit 47dc1c38df
762 changed files with 23960 additions and 10924 deletions

View file

@ -1,10 +1,13 @@
<application>
<component name="ExportableFileTemplateSettings">
<includes_templates>
<template name="Insert package.java" file-name="InsertPackage" reformat="true" live-template-enabled="false" />
</includes_templates>
<default_templates>
<template name="Decompose component.kt" file-name="${NAME}Component" reformat="true" live-template-enabled="false">
<template name="Decompose component.kt.child.5.kt" file-name="di/${NAME}ModelModule" reformat="true" live-template-enabled="false" />
<template name="Decompose component.kt.child.4.kt" file-name="di/${NAME}ComponentModule" reformat="true" live-template-enabled="false" />
<template name="Decompose component.kt.child.0.kt" file-name="entity/${NAME}UM" reformat="true" live-template-enabled="false" />
<template name="Decompose component.kt.child.4.kt" file-name="di/${NAME}ComponentModule" reformat="true" live-template-enabled="false" />
<template name="Decompose component.kt.child.5.kt" file-name="di/${NAME}ModelModule" reformat="true" live-template-enabled="false" />
<template name="Decompose component.kt.child.1.kt" file-name="ui/${NAME}Content" reformat="true" live-template-enabled="false" />
<template name="Decompose component.kt.child.2.kt" file-name="model/${NAME}Model" reformat="true" live-template-enabled="false" />
<template name="Decompose component.kt.child.3.kt" file-name="Default${NAME}Component" reformat="true" live-template-enabled="false" />
@ -16,17 +19,19 @@
<template name="Decompose component.kt.child.4.kt" file-name="di/${NAME}ComponentModule" reformat="true" live-template-enabled="false" />
<template name="Decompose component.kt.child.5.kt" file-name="di/${NAME}ModelModule" reformat="true" live-template-enabled="false" />
<template name="FeatureToggles.kt" file-name="${NAME}FeatureToggles" reformat="true" live-template-enabled="false">
<template name="FeatureToggles.kt.child.0.kt" file-name="Default${NAME}FeatureToggles" reformat="true" live-template-enabled="false" />
<template name="FeatureToggles.kt.child.0.kt" file-name="di/${NAME}FeatureTogglesModule" reformat="true" live-template-enabled="false" />
<template name="FeatureToggles.kt.child.1.kt" file-name="Default${NAME}FeatureToggles" reformat="true" live-template-enabled="false" />
</template>
<template name="FeatureToggles.kt.child.0.kt" file-name="Default${NAME}FeatureToggles" reformat="true" live-template-enabled="false" />
<template name="FeatureToggles.kt.child.0.kt" file-name="di/${NAME}FeatureTogglesModule" reformat="true" live-template-enabled="false" />
<template name="FeatureToggles.kt.child.1.kt" file-name="Default${NAME}FeatureToggles" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt" file-name="producer/${NAME}Producer" reformat="true" live-template-enabled="false">
<template name="FlowPackaging.kt.child.0.kt" file-name="di/${NAME}FetcherModule" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt.child.1.kt" file-name="di/${NAME}ProducerFactoryModule" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt.child.2.kt" file-name="di/${NAME}SupplierModule" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt.child.3.kt" file-name="fetcher/Default${NAME}Fetcher" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt.child.4.kt" file-name="producer/Default${NAME}Producer" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt.child.5.kt" file-name="fetcher/${NAME}Fetcher" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt.child.1.kt" file-name="di/${NAME}ProducerFactoryModule" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt.child.0.kt" file-name="di/${NAME}FetcherModule" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt.child.4.kt" file-name="producer/Default${NAME}Producer" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt.child.3.kt" file-name="fetcher/Default${NAME}Fetcher" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt.child.6.kt" file-name="supplier/${NAME}Supplier" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt.child.2.kt" file-name="di/${NAME}SupplierModule" reformat="true" live-template-enabled="false" />
</template>
<template name="FlowPackaging.kt.child.0.kt" file-name="di/${NAME}FetcherModule" reformat="true" live-template-enabled="false" />
<template name="FlowPackaging.kt.child.1.kt" file-name="di/${NAME}ProducerFactoryModule" reformat="true" live-template-enabled="false" />

View file

@ -1,9 +1,4 @@
#if ($TOGGLE_NAME && $TOGGLE_NAME != "")
#set($TOGGLE = $TOGGLE_NAME)
#else
#set($TOGGLE = "TODO")
#end
#if (${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
#if (${PACKAGE_NAME} != "")package ${PACKAGE_NAME}#end
interface ${NAME}FeatureToggles {

View file

@ -1,16 +1,18 @@
#if ($TOGGLE_NAME && $TOGGLE_NAME != "")
#set($TOGGLE = $TOGGLE_NAME)
#else
#set($TOGGLE = "TODO")
#end
#if (${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end
#if (${PACKAGE_NAME} != "")package ${PACKAGE_NAME}.di#end
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import ${PACKAGE_NAME}.Default${NAME}FeatureToggles
import ${PACKAGE_NAME}.${NAME}FeatureToggles
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
internal class Default${NAME}FeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : ${NAME}FeatureToggles {
@Module
@InstallIn(SingletonComponent::class)
internal interface ${NAME}FeatureTogglesModule {
override val isFeatureEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "$TOGGLE")
@Binds
@Singleton
fun bind${NAME}FeatureToggles(impl: Default${NAME}FeatureToggles): ${NAME}FeatureToggles
}

View file

@ -0,0 +1,13 @@
#if (${PACKAGE_NAME} != "")package ${PACKAGE_NAME}#end
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import javax.inject.Inject
internal class Default${NAME}FeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : ${NAME}FeatureToggles {
override val isFeatureEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.)
}

View file

@ -160,6 +160,7 @@ dependencies {
implementation(projects.domain.hotWallet)
implementation(projects.domain.news)
implementation(projects.domain.earn)
implementation(projects.domain.tokensync)
implementation(projects.common)
implementation(projects.common.routing)
@ -189,6 +190,7 @@ dependencies {
implementation(projects.data.common)
implementation(projects.data.settings)
implementation(projects.data.tokens)
implementation(projects.data.tokensync)
implementation(projects.data.txhistory)
implementation(projects.data.wallets)
implementation(projects.data.analytics)
@ -252,6 +254,8 @@ dependencies {
implementation(projects.features.markets.impl)
implementation(projects.features.feed.api)
implementation(projects.features.feed.impl)
implementation(projects.features.promoBanners.api)
implementation(projects.features.promoBanners.impl)
implementation(projects.features.onramp.api)
implementation(projects.features.onramp.impl)
implementation(projects.features.onboardingV2.api)
@ -366,6 +370,7 @@ dependencies {
implementation(deps.googlePlay.advertising)
coreLibraryDesugaring(deps.desugar)
implementation(deps.timber)
implementation(deps.kermit)
implementation(deps.reKotlin)
implementation(deps.zxing.qrCore)
implementation(deps.coil)

View file

@ -1,6 +1,7 @@
package com.tangem.common
import androidx.test.core.app.ApplicationProvider
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.tap.ApplicationEntryPoint
import com.tangem.tap.TangemApplication
import dagger.hilt.android.testing.OnComponentReadyRunner
@ -8,15 +9,16 @@ import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import timber.log.Timber
import java.lang.reflect.Field
class ApplicationInjectionExecutionRule(
private val toggleStates: Map<String, Boolean>
private val toggleStates: Map<String, Boolean>,
) : TestRule {
private val tangemApplication: TangemApplication
get() = ApplicationProvider.getApplicationContext()
private var originalFeatureTogglesValues: Map<String, String>? = null
private var originalVersionValues: Map<FeatureToggles, String>? = null
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
@ -25,7 +27,7 @@ class ApplicationInjectionExecutionRule(
overrideFeatureToggles()
OnComponentReadyRunner.addListener(
tangemApplication, ApplicationEntryPoint::class.java
tangemApplication, ApplicationEntryPoint::class.java,
) { _: ApplicationEntryPoint ->
tangemApplication.preInit()
tangemApplication.init()
@ -40,42 +42,25 @@ class ApplicationInjectionExecutionRule(
}
}
@Suppress("UNCHECKED_CAST")
private fun saveOriginalFeatureToggles() {
try {
val featureTogglesClass = Class.forName("com.tangem.core.configtoggle.FeatureToggles")
val valuesField = featureTogglesClass.getDeclaredField("values")
valuesField.isAccessible = true
originalFeatureTogglesValues = valuesField.get(null) as Map<String, String>
originalVersionValues = FeatureToggles.entries.associateWith { it.version }
} catch (e: Exception) {
Timber.e("Failed to save original toggles values: ${e.message}")
}
}
@Suppress("UNCHECKED_CAST")
private fun overrideFeatureToggles() {
try {
val featureTogglesClass = Class.forName("com.tangem.core.configtoggle.FeatureToggles")
val valuesField = featureTogglesClass.getDeclaredField("values")
valuesField.isAccessible = true
val versionField = getVersionField()
val originalValues = originalFeatureTogglesValues ?:
(valuesField.get(null) as Map<String, String>)
val newValues = originalValues.toMutableMap()
toggleStates.forEach { (toggle, enabled) ->
if (enabled) {
newValues[toggle] = "1.0.0"
} else {
newValues.remove(toggle)
}
FeatureToggles.entries.forEach { toggle ->
val enabled = toggleStates[toggle.rawName] ?: return@forEach
val newVersion = if (enabled) ENABLED_VERSION else DISABLED_VERSION
versionField.set(toggle, newVersion)
}
valuesField.set(null, newValues)
Timber.i("FeatureToggles.values updated: $toggleStates")
} catch (e: Exception) {
Timber.e("FeatureToggles.values didn't change with error: ${e.message}")
}
@ -83,15 +68,27 @@ class ApplicationInjectionExecutionRule(
private fun restoreOriginalFeatureToggles() {
try {
if (originalFeatureTogglesValues != null) {
val featureTogglesClass = Class.forName("com.tangem.core.configtoggle.FeatureToggles")
val valuesField = featureTogglesClass.getDeclaredField("values")
valuesField.isAccessible = true
valuesField.set(null, originalFeatureTogglesValues)
Timber.i("FeatureToggles.values restored")
val saved = originalVersionValues ?: return
val versionField = getVersionField()
saved.forEach { (toggle, originalVersion) ->
versionField.set(toggle, originalVersion)
}
Timber.i("FeatureToggles.values restored")
} catch (e: Exception) {
Timber.e("FeatureToggles.values didn't restored with error: ${e.message}")
}
}
private fun getVersionField(): Field {
val field = FeatureToggles::class.java.getDeclaredField("version")
field.isAccessible = true
return field
}
private companion object {
const val ENABLED_VERSION = "1.0.0"
const val DISABLED_VERSION = "undefined"
}
}

View file

@ -30,6 +30,8 @@ import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import javax.inject.Inject
abstract class BaseTestCase : TestCase(
@ -68,6 +70,12 @@ abstract class BaseTestCase : TestCase(
*/
val composeTestRule = createEmptyComposeRule()
private val semanticTreePrinterRule = object : TestWatcher() {
override fun failed(e: Throwable?, description: Description?) {
runCatching { printAllRoots() }
}
}
@Rule
@JvmField
val ruleChain: TestRule = RuleChain
@ -76,6 +84,7 @@ abstract class BaseTestCase : TestCase(
.around(permissionRule)
.around(apiEnvironmentRule)
.around(composeTestRule)
.around(semanticTreePrinterRule)
/**
* Initialization order is important:
@ -140,6 +149,16 @@ abstract class BaseTestCase : TestCase(
.printToLog(tag, maxDepth)
}
fun printAllRoots(
tag: String = "ComposeTree",
) {
val roots = composeTestRule.onAllNodes(isRoot())
val count = roots.fetchSemanticsNodes().size
repeat(count) { index ->
roots[index].printToLog("$tag[$index]")
}
}
fun waitForIdle() = composeTestRule.waitForIdle()
private fun applicationInjectionRule(): ApplicationInjectionExecutionRule {

View file

@ -1,10 +1,14 @@
package com.tangem.common.extensions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.components.buttons.actions.HasBadgeKey
import com.tangem.core.ui.components.buttons.actions.IsDimmedKey
import io.github.kakaocup.compose.node.element.KNode
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
fun assertElementDoesNotExist(
elementProvider: () -> KNode,
@ -42,4 +46,51 @@ fun Any.assertHasBadge(expectedValue: Boolean = true) {
is KNode, is LazyListItemNode -> this.assert(matcher)
else -> throw IllegalArgumentException("Unsupported type: ${this::class}")
}
}
fun List<SemanticsNode>.assertSortedByVolumeDescending() {
val volumes = this.mapNotNull { parseVolume(it) }
assertFalse("Trading volumes list should not be empty", volumes.isEmpty())
assertTrue(
"Exchanges list should be sorted by volume in descending order",
volumes == volumes.sortedDescending(),
)
}
fun List<SemanticsNode>.assertExchangeTypesAreCexOrDex() {
assertFalse("Exchange types list should not be empty", isEmpty())
forEach { node ->
val text = extractText(node)
assertTrue(
"Exchange type should be 'CEX' or 'DEX', but found: $text",
text == "CEX" || text == "DEX",
)
}
}
fun List<SemanticsNode>.assertTrustScoresValid() {
val validScores = setOf("Risky", "Caution", "Trusted")
assertFalse("Trust scores list should not be empty", isEmpty())
forEach { node ->
val text = extractText(node)
assertTrue(
"Trust score should be one of $validScores, but found: $text",
text in validScores,
)
}
}
/**
* Extracts the text value from a semantic node's config.
*/
private fun extractText(node: SemanticsNode): String? {
if (SemanticsProperties.Text in node.config) {
return node.config[SemanticsProperties.Text].firstOrNull()?.text
}
return null
}
private fun parseVolume(node: SemanticsNode): Double? {
val text = extractText(node) ?: return null
return text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull()
}

View file

@ -1,6 +1,8 @@
package com.tangem.common.extensions
import androidx.compose.ui.test.ComposeTimeoutException
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeTestRule
import io.github.kakaocup.compose.node.element.KNode
fun KNode.clickWithAssertion() {
@ -34,3 +36,34 @@ fun KNode.assertVisibility(shouldBeDisplayed: Boolean) {
assertIsNotDisplayed()
}
}
fun KNode.clickAndWaitFor(
rule: ComposeTestRule,
timeoutMs: Long = 5_000,
maxRetries: Int = 3,
expectedCondition: () -> Unit,
) {
for (attempt in 1..maxRetries) {
performClick()
rule.waitForIdle()
try {
rule.waitUntil(timeoutMs) {
runCatching { expectedCondition() }.isSuccess
}
return
} catch (_: ComposeTimeoutException) {
}
}
throw AssertionError("Condition not met after $maxRetries click attempts")
}
fun KNode.performTextInputInChunks(
text: String,
chunkSize: Int = 2
) {
text.chunked(chunkSize).forEach { chunk ->
performTextInput(chunk)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.common.extensions
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.wallet.R
@ -46,7 +47,23 @@ fun BaseTestCase.swipeMarketsBlock(direction: SwipeDirection) {
}
fun BaseTestCase.openTheAppFromRecents() {
device.uiDevice.waitForIdle()
device.uiDevice.pressRecentApps()
device.uiDevice.waitForIdle()
val recentsOpened = device.uiDevice.wait(
Until.hasObject(By.res("com.android.launcher3:id/snapshot")),
3_000
)
if (!recentsOpened) {
device.uiDevice.pressRecentApps()
device.uiDevice.wait(
Until.hasObject(By.res("com.android.launcher3:id/snapshot")),
3_000
)
}
val centerX = device.uiDevice.displayWidth / 2
val centerY = device.uiDevice.displayHeight / 3
@ -54,6 +71,14 @@ fun BaseTestCase.openTheAppFromRecents() {
device.uiDevice.click(centerX, centerY)
}
fun BaseTestCase.collapseAppByHomeButton() {
device.uiDevice.pressHome()
device.uiDevice.wait(
Until.hasObject(By.pkg(device.uiDevice.launcherPackageName)),
3_000
)
}
fun BaseTestCase.disableWiFi() {
device.uiDevice.executeShellCommand("svc wifi disable")
}

View file

@ -1,8 +1,12 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeMarketsBlock
import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onMainScreen
import com.tangem.screens.onMarketsExchangesScreen
import com.tangem.screens.onMarketsScreen
import com.tangem.screens.onMarketsTokenDetailsScreen
import io.qameta.allure.kotlin.Allure.step
@ -23,4 +27,55 @@ fun BaseTestCase.openMarketTokenDetailsScreen(blockchainName: String, tokenName:
waitForIdle()
onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
}
}
fun BaseTestCase.assertMarketsExchangesScreen() {
step("Assert 'Title' is displayed") {
onMarketsExchangesScreen { exchangesTitle.assertIsDisplayed() }
}
step("Assert 'Exchange name' is displayed") {
onMarketsExchangesScreen { exchangeName.assertIsDisplayed() }
}
step("Assert 'Logo' is displayed") {
onMarketsExchangesScreen { exchangeLogo.assertIsDisplayed() }
}
step("Assert 'Exchange type' is displayed") {
onMarketsExchangesScreen { exchangeType.assertIsDisplayed() }
}
step("Assert 'Trust score' is displayed") {
onMarketsExchangesScreen { trustScore.assertIsDisplayed() }
}
}
fun BaseTestCase.openMarketsScreen() {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' screen") {
swipeMarketsBlock(SwipeDirection.UP)
waitForIdle()
}
}
fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAllButton: Boolean = false) {
openMarketsScreen()
if (shouldClickSeeAllButton)
step("Click on 'See all' button") {
onMarketsScreen { seeAllButton.clickWithAssertion() }
}
step("Click on '$tokenName' token") {
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle()
}
step("Scroll down") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
}
step("Click on 'Listed on exchanges' block") {
onMarketsScreen { listedOnBlockContainer.performClick() }
waitForIdle()
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseBottomSheetTestTags
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 AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddTokenBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
hasText(getResourceString(R.string.common_add_token))
}
val addButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_add))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAddTokenBottomSheet(function: AddTokenBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,71 @@
package com.tangem.screens
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasTestTag
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.features.onramp.impl.R
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 MarketsExchangesPageObject(private val provider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsExchangesPageObject>(semanticsProvider = provider) {
fun allTradingVolumeNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT_TEXT))
.fetchSemanticsNodes()
fun allExchangeTypeNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasParent(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_PRICE))))
.fetchSemanticsNodes()
fun allTrustScoreNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT)))
.fetchSemanticsNodes()
val exchangesTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.markets_token_details_exchanges_title))
useUnmergedTree = true
}
val exchangeName: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
}
val exchangeLogo: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_ICON)
}
val tradingVolume: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT)
}
val exchangeType: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_PRICE)
}
val trustScore: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT)
}
val tryAgainButton: KNode = child {
hasText(getResourceString(R.string.alert_button_try_again))
}
val errorMessage: KNode = child {
hasText(getResourceString(R.string.markets_loading_error_title))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onMarketsExchangesScreen(function: MarketsExchangesPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -35,6 +35,25 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
useUnmergedTree = true
}
val listedOnExchangesCount: KNode = child {
hasTestTag(MarketsTestTags.LISTED_ON_EXCHANGES_COUNT)
useUnmergedTree = true
}
val listedOnBlockContainer: KNode = child {
hasText(getResourceString(R.string.markets_token_details_listed_on), substring = true)
}
val listedOnEmptyText: KNode = child {
hasText(getResourceString(R.string.markets_token_details_empty_exchanges))
useUnmergedTree = true
}
val seeAllButton: KNode = child {
hasText(getResourceString(com.tangem.core.ui.R.string.common_see_all))
useUnmergedTree = true
}
fun tokenWithTitle(title: String): KNode {
return child {
hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM)

View file

@ -23,6 +23,11 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
useUnmergedTree = true
}
val addressesShimmer: KNode = child {
hasTestTag(SendAddressScreenTestTags.ADDRESSES_SHIMMER)
useUnmergedTree = true
}
val topAppBarTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(CoreUiR.string.common_address))
@ -62,7 +67,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
}
val clearTextFieldButton: KNode = child {
hasContentDescription(getResourceString(CoreUiR.string.common_close))
hasTestTag(SendAddressScreenTestTags.CROSS_ICON)
useUnmergedTree = true
}

View file

@ -5,6 +5,7 @@ import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.AppBarWithSearchTestTags
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.test.MarketsTestTags
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
@ -46,6 +47,13 @@ class SwapChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv
}
useUnmergedTree = true
}
fun marketsTokenWithTitle(title: String): KNode {
return child {
hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM)
hasText(title)
}
}
}
internal fun BaseTestCase.onSwapChooseTokenScreen(function: SwapChooseTokenPageObject.() -> Unit) =

View file

@ -3,10 +3,7 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.SearchBarTestTags
import com.tangem.core.ui.test.SwapSelectTokenScreenTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.core.ui.test.*
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
@ -48,6 +45,11 @@ class SwapSelectTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv
useUnmergedTree = true
}
val searchBarBlock: KNode = child {
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
useUnmergedTree = true
}
val searchBarIcon: KNode = child {
hasTestTag(SearchBarTestTags.ICON)
useUnmergedTree = true
@ -58,12 +60,28 @@ class SwapSelectTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv
useUnmergedTree = true
}
val tryAgainButton: KNode = child {
hasText(getResourceString(R.string.alert_button_try_again))
useUnmergedTree = true
}
val unableToLoadData: KNode = child {
hasText(getResourceString(R.string.markets_loading_error_title))
useUnmergedTree = true
}
fun tokenWithName(tokenName: String): KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
hasAnyChild(withText(tokenName))
useUnmergedTree = true
}
fun marketsTokenWithName(title: String): KNode {
return child {
hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM)
hasText(title)
}
}
}
internal fun BaseTestCase.onSwapSelectTokenScreen(function: SwapSelectTokenPageObject.() -> Unit) =

View file

@ -52,8 +52,8 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasTestTag(SwapTokenScreenTestTags.RECEIVE_AMOUNT_SHIMMER)
}
val swapTokensOnscreenButton: KNode = child {
hasTestTag(SwapTokenScreenTestTags.SWAP_BUTTON)
val replaceTokensButton: KNode = child {
hasTestTag(SwapTokenScreenTestTags.REPLACE_TOKENS_BUTTON)
}
val receiveAmount: KNode = child {

View file

@ -14,6 +14,7 @@ class TopBarPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
) {
val moreButton: KNode = child {
hasTestTag(MainScreenTestTags.MORE_BUTTON)
useUnmergedTree = true
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.domain.redux.StateDialog
import com.tangem.scenarios.checkFailedTransactionDialog
@ -18,6 +19,7 @@ import com.tangem.tap.store
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
@ -45,6 +47,7 @@ class FeedbackTest : BaseTestCase() {
onTopBar { moreButton.clickWithAssertion() }
}
step("Click 'Contact support' button") {
waitForIdle()
onDetailsScreen { contactSupportButton.clickWithAssertion() }
}
step("Assert 'Gmail' app is open") {
@ -53,6 +56,7 @@ class FeedbackTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("893")
@DisplayName("Send feedback: failed transaction")
@Test
@ -94,17 +98,29 @@ class FeedbackTest : BaseTestCase() {
step("Enter address") {
onSendAddressScreen { addressTextField.performTextInput(recipientAddress) }
}
step("Click 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
step("Assert text field contains text: $recipientAddress") {
onSendAddressScreen { addressTextField.assertTextEquals(recipientAddress) }
}
step("Assert sеnding text is displayed") {
onSendConfirmScreen { sendingText.assertIsDisplayed() }
step("Click 'Next' button") {
onSendAddressScreen {
nextButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onSendConfirmScreen { sendingText.assertIsDisplayed() }
}
)
}
}
step("Click 'Send' button") {
waitForIdle()
onSendConfirmScreen {
sendButton.assertIsEnabled()
sendButton.performClick()
sendButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onFailedTransactionDialog { dialogContainer.assertIsDisplayed() }
}
)
}
}
step("Check 'Failed transaction' dialog") {

View file

@ -14,6 +14,7 @@ import com.tangem.tap.domain.sdk.mocks.content.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
@ -85,6 +86,7 @@ class ScanCardTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("870")
@DisplayName("Scan: Card with Ed25519 curve")
@Test

View file

@ -20,6 +20,7 @@ import com.tangem.tap.domain.sdk.mocks.content.TwinsMockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
@ -460,6 +461,7 @@ class MainScreenActionButtonsTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("4396")
@DisplayName("Action buttons (main screen): click on buttons without data")
@Test

View file

@ -113,6 +113,7 @@ class TotalBalanceUnavailableTest : BaseTestCase() {
val scenarioName = "user_tokens_api"
val scenarioState = "CustomTokenAdded"
val tokenTitle = "Myria"
val balance = "$3,299.18"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
@ -134,8 +135,8 @@ class TotalBalanceUnavailableTest : BaseTestCase() {
}
}
}
step("Assert dash sign is displayed in total balance") {
onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) }
step("Assert correct balance is displayed in total balance") {
onMainScreen { totalBalanceText.assertTextContains(balance) }
}
step("Assert $tokenTitle is unreachable") {
onMainScreen { tokenWithTitleAndPosition(tokenTitle, 4).assertIsUnreachable() }

View file

@ -126,7 +126,7 @@ class TotalBalanceUpdateTest : BaseTestCase() {
onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) }
}
step("Press 'Home' to collapse the app") {
device.uiDevice.pressHome()
collapseAppByHomeButton()
}
step("Open the app from recent apps") {
openTheAppFromRecents()

View file

@ -0,0 +1,200 @@
package com.tangem.tests.markets
import com.tangem.common.BaseTestCase
import com.tangem.common.annotations.ApiEnv
import com.tangem.common.annotations.ApiEnvConfig
import com.tangem.common.extensions.*
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.scenarios.assertMarketsExchangesScreen
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openMarketsExchangesScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMarketsExchangesScreen
import com.tangem.screens.onMarketsScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class MarketsExchangesTest : BaseTestCase() {
@Test
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@AllureId("58")
@DisplayName("Markets: verify exchanges list screen")
fun marketsExchangesListTest() {
val tokenName = "Solana"
setupHooks().run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName = tokenName, shouldClickSeeAllButton = true)
}
step("Assert 'Exchanges' list screen is displayed") {
assertMarketsExchangesScreen()
}
}
}
@Test
@AllureId("56")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@DisplayName("Markets: verify exchanges block is displayed in token details")
fun marketsExchangesBlockDisplayedTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' screen") {
swipeMarketsBlock(SwipeDirection.UP)
waitForIdle()
}
step("Click on '$tokenName' token") {
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle()
}
step("Scroll down") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
}
step("Assert 'Listed on exchanges' block has title") {
onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() }
}
step("Assert 'Listed on exchanges' block has exchanges count") {
onMarketsScreen { listedOnExchangesCount.assertIsDisplayed() }
}
step("Assert 'Listed on exchanges' block has arrow button") {
onMarketsScreen { listedOnBlockContainer.assertHasClickAction() }
}
step("Tap on 'Listed on exchanges' block and navigate to exchanges list") {
onMarketsScreen { listedOnBlockContainer.performClick() }
waitForIdle()
}
step("Assert 'Exchanges' list screen is displayed") {
assertMarketsExchangesScreen()
}
}
}
@Test
@AllureId("60")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@DisplayName("Markets: verify exchanges list is sorted by volume descending")
fun marketsExchangesListSortedByVolumeTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert exchanges are sorted by trading volume in descending order") {
flakySafely {
onMarketsExchangesScreen { allTradingVolumeNodes().assertSortedByVolumeDescending() }
}
}
}
}
@Test
@AllureId("61")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@DisplayName("Markets: verify exchange types are CEX or DEX")
fun marketsExchangesTypeTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert exchange types list is not empty and all types are 'CEX' or 'DEX'") {
flakySafely {
onMarketsExchangesScreen { allExchangeTypeNodes().assertExchangeTypesAreCexOrDex() }
}
}
}
}
@Test
@AllureId("62")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@DisplayName("Markets: verify exchange trust scores are valid")
fun marketsExchangesTrustScoreTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert trust scores list is not empty and all scores are valid") {
flakySafely {
onMarketsExchangesScreen { allTrustScoreNodes().assertTrustScoresValid() }
}
}
}
}
@Test
@AllureId("57")
@DisplayName("Markets: verify empty exchanges state")
fun marketsExchangesEmptyTest() {
val tokenName = "Bitcoin"
val scenarioName = "coins_bitcoin"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(scenarioName = scenarioName, state = "EmptyExchanges")
},
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
},
).run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert 'Listed on exchanges' block shows no exchanges") {
onMarketsScreen { listedOnEmptyText.assertIsDisplayed() }
}
}
}
@Test
@AllureId("59")
@DisplayName("Markets: verify exchanges error state and retry")
fun marketsExchangesErrorStateTest() {
val tokenName = "Bitcoin"
val scenarioName = "bitcoin_exchange"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(scenarioName = scenarioName, state = "Unreachable")
},
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
},
).run {
step("Open 'Markets Exhanges Screen with token: $tokenName'") {
openMarketsExchangesScreen(tokenName)
}
step("Assert 'Unable to load data' error state is displayed") {
step("Assert 'Error message' is displayed") {
onMarketsExchangesScreen { errorMessage.assertIsDisplayed() }
}
step("Assert 'Try again button' is displayed") {
onMarketsExchangesScreen { tryAgainButton.assertIsDisplayed() }
}
}
step("Set WireMock scenario '$scenarioName' to state 'Started'") {
setWireMockScenarioState(scenarioName = scenarioName, state = "Started")
}
step("Tap 'Try again' button") {
onMarketsExchangesScreen { tryAgainButton.clickWithAssertion() }
waitForIdle()
}
step("Assert 'Exchanges' list screen is displayed after retry") {
assertMarketsExchangesScreen()
}
}
}
}

View file

@ -8,10 +8,12 @@ import com.tangem.common.constants.TestConstants.ENS_ETHEREUM_RECIPIENT_SHORTENE
import com.tangem.common.constants.TestConstants.ENS_NAME
import com.tangem.common.constants.TestConstants.ETHEREUM_ADDRESS
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.constants.TestConstants.XRP_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.XRP_X_ADDRESS
import com.tangem.common.constants.TestConstants.XRP_X_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.XRP_X_RECIPIENT_ADDRESS_WITH_TAG
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.clearClipboard
import com.tangem.common.utils.resetWireMockScenarioState
@ -115,6 +117,9 @@ class SendAddressScreenTest : BaseTestCase() {
clearClipboard()
}
).run {
step("Set clipboard text") {
setClipboardText(context, recipientAddress)
}
step("Open 'Main Screen'") {
openMainScreen()
}
@ -124,10 +129,11 @@ class SendAddressScreenTest : BaseTestCase() {
step("Open 'Send Address' screen") {
openSendAddressScreen(tokenName, sendAmount)
}
step("Set clipboard text") {
setClipboardText(context, recipientAddress)
step("Assert 'Addresses shimmer' is not displayed") {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}
step("Click on 'Paste' button") {
waitForIdle()
onSendAddressScreen { addressPasteButton.clickWithAssertion() }
}
step("Assert address text field contains correct recipient address") {
@ -142,8 +148,22 @@ class SendAddressScreenTest : BaseTestCase() {
step("Set clipboard text") {
setClipboardText(context, invalidAddress)
}
step("Assert 'Addresses shimmer' is not displayed") {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}
step("Click on 'Cross' button") {
onSendAddressScreen { clearTextFieldButton.clickWithAssertion() }
waitForIdle()
onSendAddressScreen {
clearTextFieldButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onSendAddressScreen { addressPasteButton.assertIsDisplayed() }
}
)
}
}
step("Assert 'Addresses shimmer' is not displayed") {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}
step("Click on 'Paste' button") {
onSendAddressScreen { addressPasteButton.clickWithAssertion() }
@ -154,20 +174,38 @@ class SendAddressScreenTest : BaseTestCase() {
step("Assert invalid address text field title is displayed") {
onSendAddressScreen { addressTextFieldTitle.assertTextContains(notAValidAddress) }
}
step("Assert 'Next' button is disabled") {
onSendAddressScreen { nextButton.assertIsNotEnabled() }
}
step("Set clipboard text") {
setClipboardText(context, walletAddress)
}
step("Assert 'Next' button is disabled") {
onSendAddressScreen { nextButton.assertIsNotEnabled() }
}
step("Assert 'Addresses shimmer' is not displayed") {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}
step("Click on 'Cross' button") {
onSendAddressScreen { clearTextFieldButton.clickWithAssertion() }
onSendAddressScreen {
clearTextFieldButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onSendAddressScreen { addressPasteButton.assertIsDisplayed() }
}
)
}
}
step("Assert 'Addresses shimmer' is not displayed") {
onSendAddressScreen { addressesShimmer.assertDoesNotExist() }
}
step("Click on 'Paste' button") {
waitForIdle()
onSendAddressScreen { addressPasteButton.clickWithAssertion() }
}
step("Assert address text field contains invalid address") {
onSendAddressScreen { addressTextField.assertTextContains(walletAddress) }
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendAddressScreen {
addressTextField.assertTextContains(walletAddress)
}
}
}
step("Assert 'Address is the same as wallet address' error title is displayed") {
onSendAddressScreen { addressTextFieldTitle.assertTextContains(sameAsWalletAddress) }

View file

@ -267,22 +267,21 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) }
}
step("Turn off internet") {
disableWiFi()
disableMobileData()
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Turn on internet") {
enableWiFi()
enableMobileData()
}
step("Assert 'Network fee info unreachable' warning title is displayed") {
onSendConfirmScreen { warningTitle(warningTitle).assertIsDisplayed() }
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
waitForIdle()
onSendConfirmScreen { warningTitle(warningTitle).assertIsDisplayed() }
}
}
step("Assert 'Check your internet connection' warning message is displayed") {
onSendConfirmScreen { warningMessage(warningMessageResId).assertIsDisplayed() }
@ -290,6 +289,10 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Assert warning icon is displayed") {
onSendConfirmScreen { warningIcon(warningTitle).assertIsDisplayed() }
}
step("Turn on internet") {
enableWiFi()
enableMobileData()
}
step("Click on 'Refresh' button") {
waitForIdle()
onSendConfirmScreen {

View file

@ -17,6 +17,7 @@ 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.Ignore
import org.junit.Test
@HiltAndroidTest
@ -32,6 +33,7 @@ class StellarWarningsTest : BaseTestCase() {
getResourceString(R.string.send_notification_invalid_reserve_amount_title, reserveAmount)
private val warningMessage = getResourceString(R.string.send_notification_invalid_reserve_amount_text)
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("4287")
@DisplayName("Warnings: check warning, when sending less than reserve")
@Test
@ -85,6 +87,7 @@ class StellarWarningsTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("4286")
@DisplayName("Warnings: check warning when sending amount equal to reserve")
@Test
@ -139,6 +142,7 @@ class StellarWarningsTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("4288")
@DisplayName("Warnings: check warning when sending greater than reserve")
@Test

View file

@ -0,0 +1,262 @@
package com.tangem.tests.swap
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.performTextInputInChunks
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.SwapEntryPoint
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openSwapScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.*
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 SearchAndSwapTest : BaseTestCase() {
@AllureId("8520")
@DisplayName("Search and Swap: add token without derivation")
@Test
fun addTokenWithoutDerivationTest() {
val swapTokenName = "Ethereum"
val receiveTokenName = "Tether"
val swapTokenSymbol = "ETH"
val receiveTokenSymbol = "USDT"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on token with name '$swapTokenName'") {
onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() }
waitForIdle()
}
step("Click on 'Search' text field") {
onSwapSelectTokenScreen { searchBarPlaceholderText.performClick() }
}
step("Type '$receiveTokenName' in input text field") {
onSwapSelectTokenScreen { searchBarBlock.performTextInputInChunks(receiveTokenName) }
}
step("Click on token with name '$receiveTokenName'") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onSwapSelectTokenScreen { marketsTokenWithName(receiveTokenName).clickWithAssertion() }
}
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.performClick() }
}
step("Assert swap token symbol: '$swapTokenSymbol' is displayed") {
onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() }
}
step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") {
onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() }
}
}
}
@AllureId("8519")
@DisplayName("Search and Swap: add token with derivation")
@Test
fun addTokenWithDerivationTest() {
val swapTokenName = "Ethereum"
val receiveTokenName = "TRON"
val swapTokenSymbol = "ETH"
val receiveTokenSymbol = "TRX"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on token with name '$swapTokenName'") {
onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() }
waitForIdle()
}
step("Click on 'Search' text field") {
onSwapSelectTokenScreen { searchBarPlaceholderText.performClick() }
}
step("Type '$receiveTokenSymbol' in input text field") {
onSwapSelectTokenScreen { searchBarBlock.performTextInputInChunks(receiveTokenSymbol) }
}
step("Click on token with name '$receiveTokenName'") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onSwapSelectTokenScreen { marketsTokenWithName(receiveTokenName).clickWithAssertion() }
}
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.performClick() }
}
step("Assert swap token symbol: '$swapTokenSymbol' is displayed") {
onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() }
}
step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") {
onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() }
}
}
}
@AllureId("8523")
@DisplayName("Search and Swap: Markets error")
@Test
fun marketsErrorTest() {
val swapTokenName = "Ethereum"
val scenarioName = "coins_list_api"
val scenarioState = "Error"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
}
).run {
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName, scenarioState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on token with name '$swapTokenName'") {
onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() }
waitForIdle()
}
step("Assert 'Unable to load data...' error is displayed") {
onSwapSelectTokenScreen { unableToLoadData.assertIsDisplayed() }
}
step("Assert 'Try again' button is displayed") {
onSwapSelectTokenScreen { tryAgainButton.assertIsDisplayed() }
}
}
}
@AllureId("8522")
@DisplayName("Search and Swap: check 'Unsupported token pair' warning")
@Test
fun unsupportedTokenPairTest() {
val swapTokenName = "Ethereum"
val receiveTokenName = "Pepe"
val warningTitle = getResourceString(R.string.warning_express_unsupported_pair_title)
val warningMessage = getResourceString(R.string.warning_express_unsupported_pair_description)
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on token with name '$swapTokenName'") {
onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() }
waitForIdle()
}
step("Click on 'Search' text field") {
onSwapSelectTokenScreen { searchBarPlaceholderText.performClick() }
}
step("Type '$receiveTokenName' in input text field") {
onSwapSelectTokenScreen { searchBarBlock.performTextInputInChunks(receiveTokenName) }
}
step("Click on token with name '$receiveTokenName'") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onSwapSelectTokenScreen { marketsTokenWithName(receiveTokenName).clickWithAssertion() }
}
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.performClick() }
}
step("Assert warning title '$warningTitle' is displayed") {
onSwapTokenScreen { warningTitle(warningTitle).assertIsDisplayed() }
}
step("Assert warning message '$warningMessage' is displayed") {
onSwapTokenScreen { warningMessage(warningMessage).assertIsDisplayed() }
}
step("Assert warning icon is displayed'") {
onSwapTokenScreen { warningIcon(warningMessage).assertIsDisplayed() }
}
}
}
@AllureId("8521")
@DisplayName("Swap: search token on Swap token screen")
@Test
fun networkFeeTest() {
val tokenTitle = "Ethereum"
val swapTokenSymbol = "TRX"
val receiveTokenSymbol = "ETH"
val swapTokenName = "TRON"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails)
}
step("Click on 'Replace tokens' button") {
onSwapTokenScreen { replaceTokensButton.performClick() }
}
step("Click on 'Select token' icon") {
onSwapTokenScreen { selectTokenIcon.performClick() }
}
step("Click on 'Search' icon") {
onSwapChooseTokenScreen { searchIcon.performClick() }
}
step("Click on 'Search' text field") {
onSwapChooseTokenScreen { searchTextField.performClick() }
}
step("Type '$swapTokenSymbol' in 'Search' text field") {
onSwapChooseTokenScreen { searchTextField.performTextInputInChunks(swapTokenSymbol) }
}
step("Click on token with name: '$swapTokenName'") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onSwapChooseTokenScreen { marketsTokenWithTitle(swapTokenName).performClick() }
}
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.performClick() }
}
step("Assert swap token symbol: '$swapTokenSymbol' is displayed") {
onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() }
}
step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") {
onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() }
}
}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.tests.swap
import androidx.compose.ui.test.longClick
import androidx.test.InstrumentationRegistry.getTargetContext
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.assertHasBadge
import com.tangem.common.extensions.restartApp
import com.tangem.common.utils.resetWireMockScenarioState
@ -208,7 +209,10 @@ class SwapStoriesTest : BaseTestCase() {
}
step("Assert 'Swap' button has badge") {
waitForIdle()
onMainScreen { swapButton.assertHasBadge() }
flakySafely(WAIT_UNTIL_TIMEOUT) {
composeTestRule.mainClock.advanceTimeBy(500)
onMainScreen { swapButton.assertHasBadge() }
}
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)

View file

@ -63,7 +63,7 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert 'Swap tokens on screen' button is displayed") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT) {
swapTokensOnscreenButton.assertIsDisplayed()
replaceTokensButton.assertIsDisplayed()
}
}
}
@ -203,7 +203,7 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert 'Swap tokens on screen' button is displayed") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT) {
swapTokensOnscreenButton.assertIsDisplayed()
replaceTokensButton.assertIsDisplayed()
}
}
}
@ -307,7 +307,7 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert 'Swap tokens on screen' button is displayed") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT) {
swapTokensOnscreenButton.assertIsDisplayed()
replaceTokensButton.assertIsDisplayed()
}
}
}
@ -397,7 +397,7 @@ class SwapTokenScreenTest : BaseTestCase() {
step("Assert 'Swap tokens on screen' button is displayed") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT) {
swapTokensOnscreenButton.assertIsDisplayed()
replaceTokensButton.assertIsDisplayed()
}
}
}
@ -442,7 +442,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() }
}
step("Click on 'Swap tokens on screen' button") {
onSwapTokenScreen { swapTokensOnscreenButton.performClick() }
onSwapTokenScreen { replaceTokensButton.performClick() }
waitForIdle()
}
step("Assert new swap token symbol: '$receiveTokenSymbol' is displayed") {
@ -557,9 +557,6 @@ class SwapTokenScreenTest : BaseTestCase() {
}
}
@ApiEnv(
ApiEnvConfig(ApiConfig.ID.Express, ApiEnvironment.PROD)
)
@AllureId("8536")
@DisplayName("Swap: check switch fee type (unable to cover 'Market' and 'Fast' fee)")
@Test

View file

@ -23,7 +23,6 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
@ -139,8 +138,6 @@ interface ApplicationEntryPoint {
fun getApiConfigsManager(): ApiConfigsManager
fun getUserTokensResponseStore(): UserTokensResponseStore
fun getUserWalletsListRepository(): UserWalletsListRepository
fun getTangemHotSdk(): TangemHotSdk

View file

@ -39,7 +39,6 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
@ -219,9 +218,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val apiConfigsManager: ApiConfigsManager
get() = entryPoint.getApiConfigsManager()
private val userTokensResponseStore: UserTokensResponseStore
get() = entryPoint.getUserTokensResponseStore()
private val userWalletsListRepository
get() = entryPoint.getUserWalletsListRepository()
@ -385,7 +381,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
settingsManager = settingsManager,
uiMessageSender = uiMessageSender,
coldUserWalletBuilderFactory = coldUserWalletBuilderFactory,
userTokensResponseStore = userTokensResponseStore,
userWalletsListRepository = userWalletsListRepository,
tangemHotSdk = tangemHotSdk,
trackingContextProxy = trackingContextProxy,

View file

@ -1,5 +1,6 @@
package com.tangem.tap.common.analytics
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import javax.inject.Inject
@ -8,5 +9,5 @@ class CustomerIoFeatureToggles @Inject constructor(
) {
val isFeatureEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "CUSTOMER_IO_ENABLED")
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.CUSTOMER_IO_ENABLED)
}

View file

@ -2,11 +2,9 @@ package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLink
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -20,10 +18,9 @@ import kotlin.contracts.contract
class AppsFlyerReferralParamsHandler @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
private val setShouldShowMobileWalletPromoUseCase: SetShouldShowMobileWalletPromoUseCase,
dispatchers: CoroutineDispatcherProvider,
private val coroutineScope: AppCoroutineScope,
) {
private val coroutineScope = CoroutineScope(dispatchers.io + SupervisorJob())
private val mutex = Mutex()
fun handle(deepLink: DeepLink) {

View file

@ -6,16 +6,14 @@ import com.appsflyer.attribution.AppsFlyerRequestListener
import com.tangem.core.analytics.api.EventLogger
import com.tangem.core.analytics.api.UserIdHolder
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener
import com.tangem.tap.common.analytics.appsflyer.TangemAFConversionListener
import com.tangem.tap.common.analytics.handlers.firebase.UnderscoreAnalyticsEventConverter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import timber.log.Timber
@ -26,15 +24,13 @@ class AppsFlyerClient @AssistedInject constructor(
@ApplicationContext private val context: Context,
appsFlyerDeepLinkListener: AppsFlyerDeepLinkListener,
tangemAFConversionListener: TangemAFConversionListener,
dispatchers: CoroutineDispatcherProvider,
private val appsFlyerStore: AppsFlyerStore,
private val coroutineScope: AppCoroutineScope,
) : AppsFlyerAnalyticsClient {
private val appsFlyerLib: AppsFlyerLib = AppsFlyerLib.getInstance()
private val eventConverter = UnderscoreAnalyticsEventConverter()
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
init {
with(appsFlyerLib) {
setAppId(context.packageName)

View file

@ -1,11 +1,17 @@
package com.tangem.tap.common.log
import android.os.Build
import android.util.Log
import co.touchlab.kermit.BaseLogger
import co.touchlab.kermit.LogWriter
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import com.orhanobut.logger.AndroidLogAdapter
import com.orhanobut.logger.Logger
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.wallet.BuildConfig
import timber.log.Timber
import java.util.regex.Pattern
import com.orhanobut.logger.Logger as PrettyLogger
/**
* Tangem app logger
@ -21,32 +27,102 @@ class TangemAppLoggerInitializer(
/** Initialize */
fun initialize() {
if (IS_LOG_ENABLED) {
Logger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy()))
PrettyLogger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy()))
}
Timber.plant(tree = createTimberTree())
Logger.setLogWriters(KermitLogWriter(::finalLogOutput))
}
private fun createTimberTree(): Timber.Tree {
return object : Timber.DebugTree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
if (IS_LOG_ENABLED) {
Logger.log(priority, tag, message, t)
}
if (PERMITTED_PRIORITY.contains(priority)) {
appLogsStore.saveLogMessage(
tag = tag ?: "TangemAppLogger",
message = message,
)
}
finalLogOutput(priority = priority, tag = tag, message = message, t = t)
}
}
}
private fun finalLogOutput(priority: Int, tag: String?, message: String, t: Throwable?) {
if (IS_LOG_ENABLED) {
PrettyLogger.log(priority, tag, message, t)
}
if (PERMITTED_PRIORITY.contains(priority)) {
appLogsStore.saveLogMessage(
tag = tag ?: "TangemAppLogger",
message = message,
)
}
}
@Suppress("BooleanPropertyNaming")
private companion object {
val IS_LOG_ENABLED: Boolean = BuildConfig.LOG_ENABLED
val PERMITTED_PRIORITY = listOf(Log.ERROR, Log.INFO)
}
}
private class KermitLogWriter(
private val finalLogOutput: (priority: Int, tag: String?, message: String, t: Throwable?) -> Unit,
) : LogWriter() {
private val fqcnIgnore = setOf(
LogWriter::class.java.name,
KermitLogWriter::class.java.name,
BaseLogger::class.java.name,
Logger::class.java.name,
)
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
val priority = when (severity) {
Severity.Verbose -> PrettyLogger.VERBOSE
Severity.Debug -> PrettyLogger.DEBUG
Severity.Info -> PrettyLogger.INFO
Severity.Warn -> PrettyLogger.WARN
Severity.Error -> PrettyLogger.ERROR
Severity.Assert -> PrettyLogger.ASSERT
}
val finalTag = if (tag != KERMIT_LOGGER_DEFAULT_TAG) {
tag
} else {
/**
* like in [Timber.DebugTree.tag]
*/
@Suppress("UnnecessaryLet", "ThrowingExceptionsWithoutMessageOrCause")
Throwable().stackTrace
.first { it.className !in fqcnIgnore }
.let(::createStackElementTag)
}
finalLogOutput(priority, finalTag, message, throwable)
}
/**
* copy from [Timber.DebugTree.createStackElementTag]
*/
@Suppress("MagicNumber")
private fun createStackElementTag(element: StackTraceElement): String? {
var tag = element.className.substringAfterLast('.')
val m = ANONYMOUS_CLASS.matcher(tag)
if (m.find()) {
tag = m.replaceAll("")
}
// Tag length limit was removed in API 26.
return if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) {
tag
} else {
tag.substring(0, MAX_TAG_LENGTH)
}
}
private companion object {
private const val KERMIT_LOGGER_DEFAULT_TAG = ""
/**
* copy from [Timber.DebugTree.Companion]
*/
private const val MAX_TAG_LENGTH = 23
private val ANONYMOUS_CLASS = Pattern.compile("(\\$\\d+)+$")
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.tap.core
import co.touchlab.kermit.Logger
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.SupervisorJob
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
internal class DefaultAppCoroutineScope @Inject constructor(
dispatchers: CoroutineDispatcherProvider,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : AppCoroutineScope {
private val tag = "AppCoroutineScope"
override val coroutineContext: CoroutineContext = SupervisorJob() +
// keep IO dispatcher to avoid blocking Default with IO operations
dispatchers.io +
CoroutineName(tag) +
CoroutineExceptionHandler { context, throwable ->
val coroutineName = context[CoroutineName]?.name.orEmpty()
logError(throwable, coroutineName)
}
private fun logError(throwable: Throwable, coroutineName: String) {
Logger.withTag(tag).e(
messageString = "CoroutineName $coroutineName",
throwable = throwable,
)
val event = ExceptionAnalyticsEvent(
exception = throwable,
params = mapOf(
"source" to tag,
"coroutineName" to coroutineName,
),
)
analyticsExceptionHandler.sendException(event)
}
}

View file

@ -1,11 +1,14 @@
package com.tangem.tap.core.ui
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.ui.DesignFeatureToggles
import javax.inject.Inject
class DefaultDesignFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
private val featureTogglesManager: FeatureTogglesManager,
) : DesignFeatureToggles {
override val isRedesignEnabled: Boolean = featureTogglesManager.isFeatureEnabled("APP_REDESIGN_ENABLED")
override val isRedesignEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.APP_REDESIGN_ENABLED)
}

View file

@ -1,13 +1,13 @@
package com.tangem.tap.core.ui
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles
import javax.inject.Inject
class DefaultHoldToConfirmButtonFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
private val featureTogglesManager: FeatureTogglesManager,
) : HoldToConfirmButtonFeatureToggles {
override val isHoldToConfirmEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
"HOLD_TO_CONFIRM_BUTTON_ENABLED",
)
override val isHoldToConfirmEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.HOLD_TO_CONFIRM_BUTTON_ENABLED)
}

View file

@ -231,12 +231,22 @@ internal class DefaultTangemPayStorage @Inject constructor(
) == true
}
override suspend fun storeTangemPayEligibility(eligibility: Boolean) {
appPreferencesStore.store(key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY, value = eligibility)
override suspend fun storeTangemPayEligibility(eligibility: Set<String>) {
withContext(dispatcherProvider.io) {
appPreferencesStore.store(
key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY,
value = eligibility,
)
}
}
override suspend fun getTangemPayEligibility(): Boolean {
return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY, default = false)
override suspend fun getTangemPayEligibility(): Set<String> {
return withContext(dispatcherProvider.io) {
appPreferencesStore.getSyncOrDefault(
key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY,
default = emptySet(),
)
}
}
override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) {

View file

@ -4,6 +4,7 @@ import com.tangem.datasource.api.moonpay.MoonPayApi
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.tokens.repository.CurrenciesRepository
@ -21,8 +22,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -62,8 +61,8 @@ internal object ActivityModule {
@Provides
@Singleton
@DelayedWork
fun provideActivityDelayedWorkCoroutineScope(): CoroutineScope {
return CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun provideActivityDelayedWorkCoroutineScope(appScope: AppCoroutineScope): CoroutineScope {
return appScope
}
@Provides

View file

@ -1,14 +1,9 @@
package com.tangem.tap.di
import android.content.Context
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.domain.card.BuildConfig
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
@ -17,7 +12,6 @@ import com.tangem.tap.domain.tasks.product.BlockchainToDeriveFinder
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -38,13 +32,8 @@ internal class TangemSdkManagerModule {
visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
@GlobalUiMessageSender uiMessageSender: UiMessageSender,
appFinisher: AppFinisher,
sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
analyticsExceptionHandler: AnalyticsExceptionHandler,
blockchainToDeriveFinder: BlockchainToDeriveFinder,
analyticsEventHandler: AnalyticsEventHandler,
dispatchers: CoroutineDispatcherProvider,
analyticsErrorHandler: AnalyticsErrorHandler,
): TangemSdkManager {
return if (BuildConfig.MOCK_DATA_SOURCE) {
MockTangemSdkManager(resources = context.resources)
@ -56,13 +45,8 @@ internal class TangemSdkManagerModule {
visaCardActivationTaskFactory = visaCardActivationTaskFactory,
tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
uiMessageSender = uiMessageSender,
appFinisher = appFinisher,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
analyticsExceptionHandler = analyticsExceptionHandler,
blockchainToDeriveFinder = blockchainToDeriveFinder,
analyticsEventHandler = analyticsEventHandler,
dispatchers = dispatchers,
analyticsErrorHandler = analyticsErrorHandler,
)
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.core.decompose.ui.DefaultUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHostState
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.core.ui.message.EventMessageHandler
import com.tangem.core.ui.theme.AppThemeModeHolder
@ -30,6 +31,7 @@ internal object UiDependenciesModule {
override val vibratorHapticManager = vibratorHapticManager
override val appThemeModeHolder = appThemeModeHolder
override val globalSnackbarHostState: SnackbarHostState = SnackbarHostState()
override val globalTopSnackbarHostState: TangemTopSnackbarHostState = TangemTopSnackbarHostState()
override val eventMessageHandler: EventMessageHandler = EventMessageHandler()
override val designFeatureToggles: DesignFeatureToggles = designFeatureToggles
}

View file

@ -1,14 +1,19 @@
package com.tangem.tap.di
import android.content.Context
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.LinkHandler
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.tap.common.finisher.AndroidAppFinisher
import com.tangem.tap.common.settings.IntentSettingsManager
import com.tangem.tap.common.share.IntentShareManager
import com.tangem.tap.common.url.CustomTabsUrlOpener
import com.tangem.tap.core.DefaultAppCoroutineScope
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -18,21 +23,32 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object UtilsModule {
internal interface UtilsModule {
@Provides
@Singleton
fun provideShareManager(): ShareManager = IntentShareManager()
@Binds
fun provideAppScope(defaultAppScope: DefaultAppCoroutineScope): AppCoroutineScope
@Provides
@Singleton
fun provideUrlOpener(): UrlOpener = CustomTabsUrlOpener()
companion object {
@Provides
@Singleton
fun provideAppFinisher(@ApplicationContext context: Context): AppFinisher = AndroidAppFinisher(context)
@Provides
@Singleton
fun provideShareManager(): ShareManager = IntentShareManager()
@Provides
@Singleton
fun provideSettingsManager(@ApplicationContext context: Context): SettingsManager = IntentSettingsManager(context)
@Provides
@Singleton
fun provideUrlOpener(): UrlOpener = CustomTabsUrlOpener()
@Provides
@Singleton
fun provideAppFinisher(@ApplicationContext context: Context): AppFinisher = AndroidAppFinisher(context)
@Provides
@Singleton
fun provideSettingsManager(@ApplicationContext context: Context): SettingsManager =
IntentSettingsManager(context)
@Provides
@Singleton
fun provideLinkHandler(appRouter: AppRouter): LinkHandler = LinkHandler(appRouter)
}
}

View file

@ -40,14 +40,6 @@ internal object ManageTokensDomainModule {
return FindTokenUseCase(customTokensRepository)
}
@Provides
@Singleton
fun provideCheckIsCurrencyNotAddedUseCase(
customTokensRepository: CustomTokensRepository,
): CheckIsCurrencyNotAddedUseCase {
return CheckIsCurrencyNotAddedUseCase(customTokensRepository)
}
@Provides
@Singleton
fun provideGetSupportedNetworksUseCase(
@ -64,12 +56,6 @@ internal object ManageTokensDomainModule {
return ValidateDerivationPathUseCase(customTokensRepository)
}
@Provides
@Singleton
fun provideCheckHasLinkedTokensUseCase(repository: ManageTokensRepository): CheckHasLinkedTokensUseCase {
return CheckHasLinkedTokensUseCase(repository)
}
@Provides
@Singleton
fun provideCheckCurrencyUnsupportedUseCase(repository: ManageTokensRepository): CheckCurrencyUnsupportedUseCase {

View file

@ -8,7 +8,6 @@ import com.tangem.domain.nft.utils.NFTCleaner
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -63,9 +62,7 @@ internal object NFTDomainModule {
fun providesGetNFTAvailableNetworksUseCase(
nftRepository: NFTRepository,
singleAccountListSupplier: SingleAccountListSupplier,
currenciesRepository: CurrenciesRepository,
): GetNFTNetworksUseCase = GetNFTNetworksUseCase(
currenciesRepository = currenciesRepository,
nftRepository = nftRepository,
singleAccountListSupplier = singleAccountListSupplier,
)

View file

@ -1,7 +1,12 @@
package com.tangem.tap.di.domain
import com.tangem.domain.promo.*
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.ShouldShowPromoTokenUseCase
import com.tangem.domain.promo.ShouldShowPromoWalletUseCase
import com.tangem.domain.promo.ShouldShowStoriesUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -17,8 +22,13 @@ internal object PromoDomainModule {
fun provideShouldShowSwapPromoWalletUseCase(
promoRepository: PromoRepository,
settingsRepository: SettingsRepository,
newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles,
): ShouldShowPromoWalletUseCase {
return ShouldShowPromoWalletUseCase(promoRepository, settingsRepository)
return ShouldShowPromoWalletUseCase(
promoRepository,
settingsRepository,
newPromoBannersFeatureToggles.isNewPromoBannersEnabled,
)
}
@Provides

View file

@ -1,9 +1,12 @@
package com.tangem.tap.di.domain
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.qrscanning.usecases.ResolveQrSendTargetsUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -31,4 +34,18 @@ internal object QrScanningDomainModule {
fun provideParseQrCodeUseCase(repository: QrScanningEventsRepository): ParseQrCodeUseCase {
return ParseQrCodeUseCase(repository)
}
@Provides
@Singleton
fun provideResolveQrSendTargetsUseCase(
multiAccountListSupplier: MultiAccountListSupplier,
qrScanningEventsRepository: QrScanningEventsRepository,
userWalletsListRepository: UserWalletsListRepository,
): ResolveQrSendTargetsUseCase {
return ResolveQrSendTargetsUseCase(
multiAccountListSupplier = multiAccountListSupplier,
qrScanningEventsRepository = qrScanningEventsRepository,
userWalletsListRepository = userWalletsListRepository,
)
}
}

View file

@ -0,0 +1,50 @@
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.SyncTokensUseCase
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 provideSyncTokensUseCase(
tokenSyncRepository: TokenSyncRepository,
manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
appCoroutineScope: AppCoroutineScope,
): SyncTokensUseCase {
return SyncTokensUseCase(
tokenSyncRepository = tokenSyncRepository,
manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase,
appCoroutineScope = appCoroutineScope,
)
}
}

View file

@ -1,25 +1,22 @@
package com.tangem.tap.di.domain
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.staking.single.SingleStakingBalanceFetcher
import com.tangem.domain.staking.single.SingleStakingBalanceSupplier
import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository
@ -36,7 +33,6 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@Suppress("TooManyFunctions", "LargeClass")
internal object TokensDomainModule {
@Provides
@ -53,67 +49,6 @@ internal object TokensDomainModule {
return DefaultTokensFeatureToggles(featureTogglesManager = featureTogglesManager)
}
@Provides
@Singleton
fun provideGetCurrencyUseCase(
baseCurrencyStatusOperations: BaseCurrencyStatusOperations,
dispatchers: CoroutineDispatcherProvider,
): GetSingleCryptoCurrencyStatusUseCase {
return GetSingleCryptoCurrencyStatusUseCase(
currencyStatusOperations = baseCurrencyStatusOperations,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideGetCurrencyWarningsUseCase(
walletManagersFacade: WalletManagersFacade,
currenciesRepository: CurrenciesRepository,
currencyChecksRepository: CurrencyChecksRepository,
dispatchers: CoroutineDispatcherProvider,
baseCurrencyStatusOperations: BaseCurrencyStatusOperations,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
): GetCurrencyWarningsUseCase {
return GetCurrencyWarningsUseCase(
walletManagersFacade = walletManagersFacade,
currenciesRepository = currenciesRepository,
dispatchers = dispatchers,
currencyChecksRepository = currencyChecksRepository,
currencyStatusOperations = baseCurrencyStatusOperations,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
)
}
@Provides
@Singleton
fun provideFetchCurrencyStatusUseCase(
currenciesRepository: CurrenciesRepository,
singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
singleStakingBalanceFetcher: SingleStakingBalanceFetcher,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
stakingIdFactory: StakingIdFactory,
): FetchCurrencyStatusUseCase {
return FetchCurrencyStatusUseCase(
currenciesRepository = currenciesRepository,
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
singleStakingBalanceFetcher = singleStakingBalanceFetcher,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
stakingIdFactory = stakingIdFactory,
)
}
@Provides
@Singleton
fun provideGetCryptoCurrencyUseCase(
currenciesRepository: CurrenciesRepository,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
): GetCryptoCurrencyUseCase {
return GetCryptoCurrencyUseCase(currenciesRepository, multiWalletCryptoCurrenciesSupplier)
}
@Provides
@Singleton
fun provideGetCryptoCurrencyActionsUseCase(
@ -132,30 +67,6 @@ internal object TokensDomainModule {
)
}
@Provides
@Singleton
fun provideGetCurrencyStatusByNetworkUseCase(
currencyStatusOperations: BaseCurrencyStatusOperations,
dispatchers: CoroutineDispatcherProvider,
): GetNetworkCoinStatusUseCase {
return GetNetworkCoinStatusUseCase(
currencyStatusOperations = currencyStatusOperations,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideGetFeePaidCryptoCurrencyStatusSyncUseCase(
currenciesRepository: CurrenciesRepository,
currencyStatusOperations: BaseCurrencyStatusOperations,
): GetFeePaidCryptoCurrencyStatusSyncUseCase {
return GetFeePaidCryptoCurrencyStatusSyncUseCase(
currenciesRepository = currenciesRepository,
currencyStatusOperations = currencyStatusOperations,
)
}
@Provides
@Singleton
fun provideGetMinimumTransactionAmountSyncUseCase(
@ -168,11 +79,11 @@ internal object TokensDomainModule {
@Provides
@Singleton
fun provideIsCryptoCurrencyCoinCouldHideUseCase(
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
): IsCryptoCurrencyCoinCouldHideUseCase {
return IsCryptoCurrencyCoinCouldHideUseCase(
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
fun provideIsCryptoCurrencyCouldHideUseCase(
singleAccountListSupplier: SingleAccountListSupplier,
): IsCryptoCurrencyCouldHideUseCase {
return IsCryptoCurrencyCouldHideUseCase(
singleAccountListSupplier = singleAccountListSupplier,
)
}
@ -239,37 +150,13 @@ internal object TokensDomainModule {
return GetCurrencyCheckUseCase(currencyChecksRepository, dispatchers)
}
@Provides
@Singleton
fun provideBaseCurrencyStatusOperations(
currenciesRepository: CurrenciesRepository,
quotesRepository: QuotesRepository,
singleNetworkStatusSupplier: SingleNetworkStatusSupplier,
multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
singleStakingBalanceSupplier: SingleStakingBalanceSupplier,
multiStakingBalanceSupplier: MultiStakingBalanceSupplier,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
stakingIdFactory: StakingIdFactory,
): BaseCurrencyStatusOperations {
return BaseCurrencyStatusOperations(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
singleNetworkStatusSupplier = singleNetworkStatusSupplier,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleStakingBalanceSupplier = singleStakingBalanceSupplier,
multiStakingBalanceSupplier = multiStakingBalanceSupplier,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
stakingIdFactory = stakingIdFactory,
)
}
@Provides
@Singleton
fun provideWalletBalanceFetcher(
currenciesRepository: CurrenciesRepository,
multiWalletCryptoCurrenciesFetcher: MultiWalletCryptoCurrenciesFetcher,
userWalletsListRepository: UserWalletsListRepository,
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
expressServiceFetcher: ExpressServiceFetcher,
multiWalletAccountListFetcher: MultiWalletAccountListFetcher,
multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
@ -279,8 +166,10 @@ internal object TokensDomainModule {
dispatchers: CoroutineDispatcherProvider,
): WalletBalanceFetcher {
return WalletBalanceFetcher(
currenciesRepository = currenciesRepository,
multiWalletCryptoCurrenciesFetcher = multiWalletCryptoCurrenciesFetcher,
userWalletsListRepository = userWalletsListRepository,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
expressServiceFetcher = expressServiceFetcher,
multiWalletAccountListFetcher = multiWalletAccountListFetcher,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteStatusFetcher = multiQuoteStatusFetcher,

View file

@ -1,16 +1,16 @@
package com.tangem.tap.di.domain
import com.tangem.data.wallets.hot.TangemHotWalletSigner
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.utils.coroutines.AppCoroutineScope
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.notifications.repository.PushNotificationsRepository
import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
@ -19,13 +19,10 @@ import com.tangem.domain.transaction.WalletAddressServiceRepository
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.*
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Suppress("TooManyFunctions", "LargeClass")
@ -56,8 +53,8 @@ internal object TransactionDomainModule {
walletManagersFacade: WalletManagersFacade,
singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
dispatchers: CoroutineDispatcherProvider,
pushNotificationsRepository: PushNotificationsRepository,
appScope: AppCoroutineScope,
): SendTransactionUseCase {
return SendTransactionUseCase(
demoConfig = DemoConfig,
@ -65,7 +62,7 @@ internal object TransactionDomainModule {
transactionRepository = transactionRepository,
walletManagersFacade = walletManagersFacade,
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
parallelUpdatingScope = CoroutineScope(SupervisorJob() + dispatchers.io),
parallelUpdatingScope = appScope,
getHotWalletSigner = tangemHotWalletSignerFactory::create,
pushNotificationsRepository = pushNotificationsRepository,
)
@ -279,14 +276,12 @@ internal object TransactionDomainModule {
@Singleton
fun provideGetAvailableFeeTokensUseCase(
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
): GetAvailableFeeTokensUseCase {
return GetAvailableFeeTokensUseCase(
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
gaslessTransactionRepository = gaslessTransactionRepository,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
currencyChecksRepository = currencyChecksRepository,
)
}
@ -296,17 +291,15 @@ internal object TransactionDomainModule {
fun provideGetFeeForGaslessUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getFeeUseCase: GetFeeUseCase,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
): GetFeeForGaslessUseCase {
return GetFeeForGaslessUseCase(
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
gaslessTransactionRepository = gaslessTransactionRepository,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
getFeeUseCase = getFeeUseCase,
currencyChecksRepository = currencyChecksRepository,
)
@ -317,16 +310,14 @@ internal object TransactionDomainModule {
fun provideGetFeeForTokenUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
): GetFeeForTokenUseCase {
return GetFeeForTokenUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
currencyChecksRepository = currencyChecksRepository,
)
}
@ -344,13 +335,13 @@ internal object TransactionDomainModule {
fun provideCreateAndSendGaslessTransactionUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
getSingCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
singleAccountListSupplier: SingleAccountListSupplier,
cardSdkConfigRepository: CardSdkConfigRepository,
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
): CreateAndSendGaslessTransactionUseCase {
return CreateAndSendGaslessTransactionUseCase(
walletManagersFacade = walletManagersFacade,
getSingleCryptoCurrencyStatusUseCase = getSingCryptoCurrencyStatusUseCase,
singleAccountListSupplier = singleAccountListSupplier,
gaslessTransactionRepository = gaslessTransactionRepository,
cardSdkConfigRepository = cardSdkConfigRepository,
getHotWalletSigner = tangemHotWalletSignerFactory::create,
@ -362,16 +353,14 @@ internal object TransactionDomainModule {
fun provideEstimateFeeForTokenUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
currencyChecksRepository: CurrencyChecksRepository,
): EstimateFeeForTokenUseCase {
return EstimateFeeForTokenUseCase(
gaslessTransactionRepository = gaslessTransactionRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
currencyChecksRepository = currencyChecksRepository,
)
}
@ -381,8 +370,7 @@ internal object TransactionDomainModule {
fun provideEstimateFeeForGaslessTxUseCase(
walletManagersFacade: WalletManagersFacade,
gaslessTransactionRepository: GaslessTransactionRepository,
currenciesRepository: CurrenciesRepository,
getMultiCryptoCurrencyStatusUseCase: GetMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
estimateFeeUseCase: EstimateFeeUseCase,
currencyChecksRepository: CurrencyChecksRepository,
): EstimateFeeForGaslessTxUseCase {
@ -390,8 +378,7 @@ internal object TransactionDomainModule {
gaslessTransactionRepository = gaslessTransactionRepository,
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig,
currenciesRepository = currenciesRepository,
getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
estimateFeeUseCase = estimateFeeUseCase,
currencyChecksRepository = currencyChecksRepository,
)

View file

@ -1,9 +1,10 @@
package com.tangem.tap.di.domain
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.blockaid.BlockAidGasEstimate
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.transaction.error.FeeErrorResolver
import com.tangem.domain.yield.supply.YieldSupplyErrorResolver
@ -15,8 +16,6 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Suppress("TooManyFunctions")
@ -145,12 +144,12 @@ internal object YieldSupplyDomainModule {
fun provideYieldSupplyMinAmountUseCase(
feeRepository: FeeRepository,
quotesRepository: QuotesRepository,
currenciesRepository: CurrenciesRepository,
singleAccountListSupplier: SingleAccountListSupplier,
): YieldSupplyMinAmountUseCase {
return YieldSupplyMinAmountUseCase(
feeRepository = feeRepository,
quotesRepository = quotesRepository,
currenciesRepository = currenciesRepository,
singleAccountListSupplier = singleAccountListSupplier,
)
}
@ -159,12 +158,12 @@ internal object YieldSupplyDomainModule {
fun provideYieldSupplyGetCurrentFeeUseCase(
feeRepository: FeeRepository,
quotesRepository: QuotesRepository,
currenciesRepository: CurrenciesRepository,
singleAccountListSupplier: SingleAccountListSupplier,
): YieldSupplyGetCurrentFeeUseCase {
return YieldSupplyGetCurrentFeeUseCase(
feeRepository = feeRepository,
quotesRepository = quotesRepository,
currenciesRepository = currenciesRepository,
singleAccountListSupplier = singleAccountListSupplier,
)
}
@ -173,12 +172,12 @@ internal object YieldSupplyDomainModule {
fun provideYieldSupplyGetMaxFeeUseCase(
yieldSupplyRepository: YieldSupplyRepository,
quotesRepository: QuotesRepository,
currenciesRepository: CurrenciesRepository,
singleAccountListSupplier: SingleAccountListSupplier,
): YieldSupplyGetMaxFeeUseCase {
return YieldSupplyGetMaxFeeUseCase(
yieldSupplyRepository = yieldSupplyRepository,
quotesRepository = quotesRepository,
currenciesRepository = currenciesRepository,
singleAccountListSupplier = singleAccountListSupplier,
)
}
@ -253,12 +252,12 @@ internal object YieldSupplyDomainModule {
fun provideYieldSupplyPendingProcessorUseCase(
yieldSupplyRepository: YieldSupplyRepository,
singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
dispatcherProvider: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): YieldSupplyPendingTracker {
return YieldSupplyPendingTracker(
yieldSupplyRepository = yieldSupplyRepository,
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
coroutineScope = CoroutineScope(SupervisorJob() + dispatcherProvider.io),
coroutineScope = appScope,
)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.tap.domain.scanCard
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
/**
@ -12,5 +13,5 @@ class CardScanningFeatureToggles internal constructor(
) {
val isNewCardScanningEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NEW_CARD_SCANNING_ENABLED")
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.NEW_CARD_SCANNING_ENABLED)
}

View file

@ -6,11 +6,9 @@ import androidx.annotation.StringRes
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.Log
import com.tangem.Message
import com.tangem.TangemSdk
import com.tangem.common.*
import com.tangem.common.authentication.AuthenticationManager
import com.tangem.common.authentication.keystore.KeystoreManager
import com.tangem.common.core.*
import com.tangem.common.extensions.ByteArrayKey
@ -18,24 +16,14 @@ import com.tangem.common.extensions.hexToBytes
import com.tangem.common.services.secure.SecureStorage
import com.tangem.common.usersCode.UserCodeRepository
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.res.getStringSafe
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.crypto.bip39.DefaultMnemonic
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
@ -65,11 +53,11 @@ import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask
import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask
import com.tangem.tap.domain.twins.FinalizeTwinTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.wallet.R
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
@Suppress("TooManyFunctions", "LargeClass", "LongParameterList")
@ -80,17 +68,10 @@ internal class DefaultTangemSdkManager(
private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
private val uiMessageSender: UiMessageSender,
private val appFinisher: AppFinisher,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder,
private val analyticsEventHandler: AnalyticsEventHandler,
dispatchers: CoroutineDispatcherProvider,
private val analyticsErrorHandler: AnalyticsErrorHandler,
) : TangemSdkManager {
private val awaitInitializationMutex = Mutex()
private val tangemSdk: TangemSdk
get() = cardSdkConfigRepository.sdk
@ -101,10 +82,32 @@ internal class DefaultTangemSdkManager(
)
}
override val needEnrollBiometrics: Boolean
get() = tangemSdk.authenticationManager.needEnrollBiometrics
get() {
val isNeedEnrollBiometrics = tangemSdk.authenticationManager.needEnrollBiometrics
if (isNeedEnrollBiometrics) {
analyticsErrorHandler.sendErrorEvent(
AnalyticsEvent(
category = "TangemSdkManager",
event = "needEnrollBiometrics",
),
)
}
return isNeedEnrollBiometrics
}
override val canUseBiometry: Boolean
get() = tangemSdk.authenticationManager.canAuthenticate || needEnrollBiometrics
get() {
val isCanUseBiometry = tangemSdk.authenticationManager.canAuthenticate || needEnrollBiometrics
if (!isCanUseBiometry) {
analyticsErrorHandler.sendErrorEvent(
AnalyticsEvent(
category = "TangemSdkManager",
event = "cantUseBiometry",
),
)
}
return isCanUseBiometry
}
override val keystoreManager: KeystoreManager
get() = tangemSdk.keystoreManager
@ -115,52 +118,12 @@ internal class DefaultTangemSdkManager(
override val userCodeRequestPolicy: UserCodeRequestPolicy
get() = tangemSdk.config.userCodeRequestPolicy
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.io)
override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean {
return try {
needEnrollBiometrics
} catch (e: TangemSdkError.AuthenticationNotInitialized) {
Log.error {
"Trying to access `needEnrollBiometrics` flag when authentication manager is not initialized: " +
if (awaitInitialization) "awaiting initialization" else "failing"
}
if (awaitInitialization) {
val manager = awaitAuthenticationManagerInitialization()
if (manager.isInitialized) {
manager.needEnrollBiometrics
} else {
false
}
} else {
throw e
}
}
return needEnrollBiometrics
}
override suspend fun checkCanUseBiometry(awaitInitialization: Boolean): Boolean {
return try {
canUseBiometry
} catch (e: TangemSdkError.AuthenticationNotInitialized) {
Log.error {
"Trying to access `canUseBiometry` flag when authentication manager is not initialized: " +
if (awaitInitialization) "awaiting initialization" else "failing"
}
if (awaitInitialization) {
val manager = awaitAuthenticationManagerInitialization()
if (manager.isInitialized) {
manager.canAuthenticate || manager.needEnrollBiometrics
} else {
false
}
} else {
throw e
}
}
return canUseBiometry
}
override suspend fun scanProduct(
@ -434,59 +397,6 @@ internal class DefaultTangemSdkManager(
tangemSdk.config.userCodeRequestPolicy = policy
}
private suspend fun awaitAuthenticationManagerInitialization(): AuthenticationManager {
return awaitInitializationMutex.withLock {
var attemps = 0
do {
if (tangemSdk.authenticationManager.isInitialized) {
break
} else {
if (attemps++ >= MAX_INITIALIZE_ATTEMPTS) {
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = IllegalStateException(
"Can't initialize authentication manager after $MAX_INITIALIZE_ATTEMPTS attempts",
),
),
)
showAlert()
break
} else {
delay(timeMillis = 400)
}
}
} while (true)
tangemSdk.authenticationManager
}
}
private fun showAlert() {
uiMessageSender.send(
message = DialogMessage(
message = resourceReference(id = R.string.alert_authentication_error_message),
title = resourceReference(id = R.string.alert_authentication_error_title),
isDismissable = false,
dismissOnFirstAction = false,
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.alert_button_request_support),
onClick = {
coroutineScope.launch {
analyticsEventHandler.send(
Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.SignIn),
)
sendFeedbackEmailUseCase(FeedbackEmailType.BiometricsAuthenticationFailed)
}
},
)
},
secondActionBuilder = { cancelAction(onClick = appFinisher::finish) },
),
)
}
// region Twin-specific
override suspend fun createFirstTwinWallet(
@ -626,8 +536,4 @@ internal class DefaultTangemSdkManager(
}
}
// endregion
companion object {
private const val MAX_INITIALIZE_ATTEMPTS = 10
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.tap.domain.tokens
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.tokens.TokensFeatureToggles
@ -8,5 +9,5 @@ internal class DefaultTokensFeatureToggles(
) : TokensFeatureToggles {
override val isMultiAddressUtxoEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("MULTI_ADDRESS_UTXO_ENABLED")
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.MULTI_ADDRESS_UTXO_ENABLED)
}

View file

@ -10,10 +10,10 @@ import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.coil.ImagePreloader
import com.tangem.core.ui.components.bottomsheets.message.*
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.BottomSheetMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.core.ui.message.bottomSheetMessage
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.domain.appcurrency.FetchAppCurrenciesUseCase
import com.tangem.domain.balancehiding.BalanceHidingSettings
@ -216,30 +216,28 @@ internal class MainViewModel @Inject constructor(
if (!settings.isUpdateFromToast) {
listenToFlipsUseCase.changeUpdateEnabled(false)
val message = BottomSheetMessage.invoke(
iconResId = R.drawable.ic_eye_off_outline_24,
title = resourceReference(R.string.balance_hidden_title),
message = resourceReference(R.string.balance_hidden_description),
onDismissRequest = ::onBottomSheetDismissed,
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.balance_hidden_got_it_button),
onClick = {
onHiddenBalanceNotificationAction(isPermanent = false)
onDismissRequest()
},
)
},
secondActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.balance_hidden_do_not_show_button),
onClick = {
onHiddenBalanceNotificationAction(isPermanent = true)
onDismissRequest()
},
)
},
)
val message = bottomSheetMessage {
infoBlock {
title = resourceReference(R.string.balance_hidden_title)
body = resourceReference(R.string.balance_hidden_description)
icon(R.drawable.ic_eye_off_outline_24)
}
onDismiss { onBottomSheetDismissed() }
primaryButton {
text = resourceReference(R.string.balance_hidden_got_it_button)
onClick = {
onHiddenBalanceNotificationAction(isPermanent = false)
closeBs()
}
}
secondaryButton {
text = resourceReference(R.string.balance_hidden_do_not_show_button)
onClick = {
onHiddenBalanceNotificationAction(isPermanent = true)
closeBs()
}
}
}
messageSender.send(message)
}
}

View file

@ -13,7 +13,6 @@ import com.tangem.data.card.TransactionSignerFactory
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
@ -71,7 +70,6 @@ data class DaggerGraphState(
val uiMessageSender: UiMessageSender? = null,
val cardArworksProvider: CardArtworksProvider? = null,
val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory? = null,
val userTokensResponseStore: UserTokensResponseStore? = null,
val userWalletsListRepository: UserWalletsListRepository? = null,
val tangemHotSdk: TangemHotSdk? = null,
val trackingContextProxy: TrackingContextProxy? = null,

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@ -28,9 +29,12 @@ import com.arkivanov.essenty.backhandler.BackHandler
import com.tangem.common.routing.AppRoute
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.snackbar.TangemSnackbarHost
import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHost
import com.tangem.core.ui.message.EventMessageEffect
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.LocalRootBackgroundColor
import com.tangem.core.ui.res.LocalSnackbarHostState
import com.tangem.core.ui.res.LocalTopSnackbarHostState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.security.ProvideSecureFlagController
import com.tangem.tap.routing.component.RoutingComponent
@ -93,6 +97,16 @@ internal fun RootContent(
.padding(all = 16.dp),
hostState = snackbarHostState,
)
if (LocalRedesignEnabled.current) {
TangemTopSnackbarHost(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(all = 16.dp),
hostState = LocalTopSnackbarHostState.current,
)
}
}
}
EventMessageEffect()

View file

@ -24,7 +24,6 @@ import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.markets.tokenlist.MarketsTokenListComponent
import com.tangem.features.nft.component.NFTComponent
import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent
import com.tangem.features.onramp.component.*
@ -64,7 +63,6 @@ internal class ChildFactory @Inject constructor(
private val walletHardwareBackupComponentFactory: WalletHardwareBackupComponent.Factory,
private val disclaimerComponentFactory: DisclaimerComponent.Factory,
private val manageTokensComponentFactory: ManageTokensComponent.Factory,
private val marketsTokenListComponentFactory: MarketsTokenListComponent.FactoryScreen,
private val onrampComponentFactory: OnrampComponent.Factory,
private val onrampSuccessComponentFactory: OnrampSuccessComponent.Factory,
private val buyCryptoComponentFactory: BuyCryptoComponent.Factory,
@ -140,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.TOKEN_SYNC_BANNER -> ManageTokensSource.TOKEN_SYNC_BANNER
}
val mode = route.accountId?.let { ManageTokensMode.Account(it) } ?: ManageTokensMode.None
@ -327,6 +326,7 @@ internal class ChildFactory @Inject constructor(
amount = route.amount,
tag = route.tag,
destinationAddress = route.destinationAddress,
entryType = route.entryType.toSendEntryType(),
),
componentFactory = sendComponentFactoryV2,
)
@ -349,6 +349,7 @@ internal class ChildFactory @Inject constructor(
val source = when (route.source) {
is AppRoute.QrScanning.Source.Send -> SourceType.SEND
is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT
is AppRoute.QrScanning.Source.MainScreen -> SourceType.MAIN_SCREEN
}
createComponentChild(
context = context,
@ -457,8 +458,8 @@ internal class ChildFactory @Inject constructor(
is AppRoute.Markets -> {
createComponentChild(
context = context,
params = Unit,
componentFactory = marketsTokenListComponentFactory,
params = FeedEntryRoute.MarketTokenList,
componentFactory = feedEntryComponentFactory,
)
}
is AppRoute.Usedesk -> { // TODO [REDACTED_TASK_KEY] pass params
@ -687,4 +688,9 @@ internal class ChildFactory @Inject constructor(
}
}
}
}
private fun AppRoute.Send.EntryType.toSendEntryType(): SendComponent.EntryType = when (this) {
AppRoute.Send.EntryType.Manual -> SendComponent.EntryType.Manual
AppRoute.Send.EntryType.QR -> SendComponent.EntryType.QR
}

View file

@ -6,9 +6,9 @@ import com.tangem.common.routing.DeepLinkRoute
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler

View file

@ -5,8 +5,8 @@ import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
import com.tangem.test.core.ProvideTestModels
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import arrow.core.right
import com.tangem.common.test.TestAppCoroutineScope
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
@ -30,7 +30,7 @@ class AppsFlyerReferralParamsHandlerTest {
}
private val handler = AppsFlyerReferralParamsHandler(
appsFlyerStore = appsFlyerStore,
dispatchers = TestingCoroutineDispatcherProvider(),
coroutineScope = TestAppCoroutineScope(),
setShouldShowMobileWalletPromoUseCase = setShouldShowMobileWalletPromoUseCase,
)

View file

@ -4,9 +4,9 @@ import android.net.Uri
import com.tangem.common.routing.AppRoute
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler

View file

@ -68,13 +68,20 @@ sealed class AppRoute(val path: String) : Route {
val amount: String? = null,
val tag: String? = null,
val destinationAddress: String? = null,
val entryType: EntryType = EntryType.Manual,
) : AppRoute(
path = "/send/${userWalletId.stringValue}/${currency.id.value}?" +
"&$transactionId" +
"&$amount" +
"&$tag" +
"&$destinationAddress",
)
) {
@Serializable
enum class EntryType {
Manual,
QR,
}
}
@Serializable
data class Details(
@ -139,6 +146,7 @@ sealed class AppRoute(val path: String) : Route {
STORIES,
SETTINGS,
ACCOUNT,
TOKEN_SYNC_BANNER,
}
}
@ -167,11 +175,14 @@ sealed class AppRoute(val path: String) : Route {
get() = when (this) {
is Send -> "/$networkName"
WalletConnect -> ""
MainScreen -> ""
}
data class Send(val networkName: String) : Source()
data object WalletConnect : Source()
data object MainScreen : Source()
}
}
@ -316,7 +327,7 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class Stories(
val storyId: String,
val nextScreen: AppRoute,
val nextScreen: AppRoute? = null,
val screenSource: String,
) : AppRoute(path = "/stories$storyId")

View file

@ -0,0 +1,35 @@
package com.tangem.common.routing
import android.net.Uri
import timber.log.Timber
/**
* Routes in-app content links (deep links and external URLs)
* - `tangem://` scheme → parsed to AppRoute and pushed via AppRouter
* - `https://` / `http://` → opened in external browser via UrlOpener
* - Unknown scheme logged and ignored
*/
class LinkHandler(
private val appRouter: AppRouter,
) {
fun navigate(link: String) {
val uri = Uri.parse(link)
handleTangemDeepLink(uri)
}
private fun handleTangemDeepLink(uri: Uri) {
val route = parseDeepLinkToRoute(uri)
if (route != null) {
appRouter.push(route)
} else {
Timber.w("ContentLinkHandler: unrecognized tangem deep link: %s", uri)
}
}
@Suppress("UnusedParameter", "FunctionOnlyReturningConstant")
private fun parseDeepLinkToRoute(uri: Uri): AppRoute? {
// TODO [REDACTED_TASK_KEY] refactor deepling routing
return null
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.common.test
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.TestScope
import kotlin.coroutines.CoroutineContext
class TestAppCoroutineScope(override val coroutineContext: CoroutineContext = Dispatchers.Unconfined) : AppCoroutineScope {
constructor(testScope: TestScope) : this(testScope.coroutineContext)
}

View file

@ -0,0 +1,339 @@
package com.tangem.common.ui.markets
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.common.ui.charts.MarketChartMini
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvider
import com.tangem.common.ui.tokens.TokenPriceText
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.badge.*
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.LocalIsInDarkTheme
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.windowsize.WindowSizeType
import com.tangem.utils.StringsSigns.MINUS
import kotlin.random.Random
@Composable
fun MarketsListItemV2(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketListItemContentV2(
modifier = modifier
.fillMaxWidth()
.clip(RectangleShape)
.clickable(onClick = onClick)
.testTag(MarketsTestTags.TOKENS_LIST_ITEM),
model = model,
)
}
@Composable
fun MarketListItemContentV2(model: MarketsListItemUM, modifier: Modifier = Modifier) {
val windowSize = LocalWindowSize.current
TangemRowContainer(
modifier = modifier,
content = {
TangemIcon(
tangemIconUM = TangemIconUM.Url(model.iconUrl, fallbackRes = R.drawable.ic_custom_token_44),
modifier = Modifier
.size(40.dp)
.layoutId(layoutId = TangemRowLayoutId.HEAD),
)
TokenTitle(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.START_TOP)
.padding(horizontal = TangemTheme.dimens2.x2),
name = model.name,
currencySymbol = model.currencySymbol,
)
TokenPriceText(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.END_TOP)
.testTag(tag = TokenElementsTestTags.TOKEN_FIAT_AMOUNT),
price = model.price.text,
priceChangeType = model.price.changeType,
)
TokenSubtitle(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM)
.padding(end = TangemTheme.dimens2.x2, start = TangemTheme.dimens2.x3),
ratingPosition = model.ratingPosition,
marketCap = model.marketCap,
stakingRate = model.stakingRate,
)
PriceChangeInPercent(
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM),
textStyle = TangemTheme.typography2.captionRegular12,
type = model.trendType,
valueInPercent = model.trendPercentText,
)
if (windowSize.widthAtLeast(WindowSizeType.Small)) {
Chart(
modifier = Modifier
.padding(start = TangemTheme.dimens2.x2)
.layoutId(layoutId = TangemRowLayoutId.TAIL),
chartType = model.chartType,
chartRawData = model.chartData,
)
}
},
)
}
@Composable
private fun TokenTitle(name: String, currencySymbol: String, modifier: Modifier = Modifier) {
Row(modifier = modifier) {
Text(
modifier = Modifier
.weight(1f, fill = false)
.alignByBaseline(),
text = name,
color = TangemTheme.colors2.text.neutral.primary,
style = TangemTheme.typography2.bodySemibold16,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SpacerW4()
Text(
modifier = Modifier.alignByBaseline(),
text = currencySymbol,
color = TangemTheme.colors2.text.neutral.secondary,
style = TangemTheme.typography2.captionSemibold12,
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
}
@Composable
private fun TokenSubtitle(
ratingPosition: String?,
marketCap: String?,
stakingRate: TextReference?,
modifier: Modifier = Modifier,
) {
val ratingColor = mapRatingToColor(ratingPosition)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
TokenRatingPlace(
ratingPosition = ratingPosition,
ratingColor = ratingColor,
)
if (marketCap != null) {
TokenMarketCapText(
ratingColor = ratingColor,
modifier = Modifier.weight(1f, fill = false),
text = marketCap,
)
}
if (stakingRate != null) {
StakingRate(stakingRate = stakingRate)
}
}
}
@Composable
private fun RowScope.TokenRatingPlace(ratingPosition: String?, ratingColor: Color) {
Row(
modifier = Modifier
.alignByBaseline()
.heightIn(min = TangemTheme.dimens2.x4),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
Icon(
modifier = Modifier.size(height = TangemTheme.dimens2.x4, width = TangemTheme.dimens2.x2),
imageVector = ImageVector.vectorResource(R.drawable.ic_laurel_left),
tint = ratingColor,
contentDescription = null,
)
Text(
textAlign = TextAlign.Center,
text = ratingPosition ?: MINUS,
color = ratingColor,
style = TangemTheme.typography2.captionSemibold12.copy(letterSpacing = 0.sp),
maxLines = 1,
)
Icon(
modifier = Modifier.size(height = TangemTheme.dimens2.x4, width = TangemTheme.dimens2.x2),
imageVector = ImageVector.vectorResource(R.drawable.ic_laurel_right),
tint = ratingColor,
contentDescription = null,
)
}
}
@Composable
private fun RowScope.TokenMarketCapText(text: String, ratingColor: Color, modifier: Modifier = Modifier) {
Text(
modifier = modifier.alignByBaseline(),
text = text,
color = ratingColor,
style = TangemTheme.typography2.captionSemibold12,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun RowScope.StakingRate(stakingRate: TextReference, modifier: Modifier = Modifier) {
TangemBadge(
modifier = modifier.alignByBaseline(),
text = stakingRate,
shape = TangemBadgeShape.Rounded,
size = TangemBadgeSize.X4,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Gray,
)
}
@Composable
private fun Chart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.padding(vertical = 6.dp)
.size(height = TangemTheme.dimens2.x6, width = TangemTheme.dimens2.x12),
) {
if (chartRawData != null) {
MarketChartMini(
rawData = chartRawData,
type = chartType,
)
} else {
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens2.x3)
.align(Alignment.Center),
radius = 3.dp,
)
}
}
}
@Composable
private fun mapRatingToColor(rating: String?): Color {
val isDarkTheme = LocalIsInDarkTheme.current
return when (rating) {
"1" -> if (isDarkTheme) Color(GOLD_PLACE_COLOR_NIGHT) else Color(GOLD_PLACE_COLOR_LIGHT)
"2" -> if (isDarkTheme) Color(SILVER_PLACE_COLOR_NIGHT) else Color(SILVER_PLACE_COLOR_LIGHT)
"3" -> if (isDarkTheme) Color(BRONZE_PLACE_COLOR_NIGHT) else Color(BRONZE_PLACE_COLOR_LIGHT)
else -> TangemTheme.colors2.text.neutral.secondary
}
}
private const val GOLD_PLACE_COLOR_NIGHT = 0xFFFBEE76
private const val GOLD_PLACE_COLOR_LIGHT = 0xFFD9B900
private const val SILVER_PLACE_COLOR_NIGHT = 0xFFAABEF7
private const val SILVER_PLACE_COLOR_LIGHT = 0xFF6680CC
private const val BRONZE_PLACE_COLOR_NIGHT = 0xFFFF9976
private const val BRONZE_PLACE_COLOR_LIGHT = 0xFFCC7F66
// region preview
@Preview(showBackground = true, widthDp = 360, name = "normal")
@Composable
private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::class) state: MarketsListItemUM) {
TangemThemePreviewRedesign {
var state1 by remember { mutableStateOf(state) }
var state2 by remember { mutableStateOf(state) }
var prices by remember {
mutableStateOf(
listOf(
100 to PriceChangeType.NEUTRAL,
200 to PriceChangeType.NEUTRAL,
),
)
}
Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) {
MarketsListItemV2(
modifier = Modifier,
model = state1,
)
MarketsListItemV2(
modifier = Modifier,
model = state2,
)
Row {
Button(
onClick = {
state1 = state1.copy(
trendType = PriceChangeType.entries.random(),
)
state2 = state2.copy(
trendType = PriceChangeType.entries.random(),
)
},
) { Text(text = "trend") }
Button(
onClick = {
prices = prices.map { (price, _) ->
if (Random.nextBoolean()) {
price.inc() to PriceChangeType.UP
} else {
price.dec() to PriceChangeType.DOWN
}
}
state1 = state1.copy(
price = MarketsListItemUM.Price(
text = "0.${prices[0].first}023 $",
changeType = prices[0].second,
),
)
state2 = state2.copy(
price = MarketsListItemUM.Price(
text = "0.${prices[1].first}023 $",
changeType = prices[1].second,
),
)
},
) { Text(text = "price") }
}
}
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.common.ui.markets
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.res.LocalRedesignEnabled
@Composable
fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
if (LocalRedesignEnabled.current) {
MarketsListItemV2(
model = model,
modifier = modifier,
onClick = onClick,
)
} else {
MarketsListItemV1(
model = model,
modifier = modifier,
onClick = onClick,
)
}
}

View file

@ -4,23 +4,29 @@ import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW12
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.components.*
import com.tangem.core.ui.res.*
import com.tangem.core.ui.windowsize.WindowSizeType
@Composable
fun MarketsListItemPlaceholder() {
if (LocalRedesignEnabled.current) {
MarketsListItemPlaceholderV2()
} else {
MarketsListItemPlaceholderV1()
}
}
@Suppress("LongMethod")
@Composable
fun MarketsListItemPlaceholder() {
private fun MarketsListItemPlaceholderV1() {
val density = LocalDensity.current
val windowSize = LocalWindowSize.current
val sp12 = with(density) { 12.sp.toDp() }
@ -84,11 +90,71 @@ fun MarketsListItemPlaceholder() {
}
}
@Suppress("LongMethod")
@Composable
private fun MarketsListItemPlaceholderV2() {
val windowSize = LocalWindowSize.current
Row(
modifier = Modifier.padding(
horizontal = 12.dp,
vertical = 14.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
CircleShimmer(modifier = Modifier.size(40.dp))
SpacerW(4.dp)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.Start,
) {
RectangleShimmer(
modifier = Modifier
.width(96.dp)
.height(20.dp),
radius = TangemTheme.dimens2.x25,
)
RectangleShimmer(
modifier = Modifier
.width(46.dp)
.height(16.dp),
radius = TangemTheme.dimens2.x25,
)
}
if (windowSize.widthAtLeast(WindowSizeType.Small)) {
SpacerW(10.dp)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.End,
) {
RectangleShimmer(
modifier = Modifier
.width(56.dp)
.height(20.dp),
radius = TangemTheme.dimens2.x25,
)
RectangleShimmer(
modifier = Modifier
.width(46.dp)
.height(16.dp),
radius = TangemTheme.dimens2.x25,
)
}
}
}
}
@Preview(showBackground = true, widthDp = 360, name = "normal")
@Preview(showBackground = true, widthDp = 360, name = "normal night", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 320, name = "small width")
@Composable
private fun Preview() {
private fun PreviewV1() {
TangemThemePreview {
Column(Modifier.background(TangemTheme.colors.background.tertiary)) {
repeat(20) {
@ -96,4 +162,20 @@ private fun Preview() {
}
}
}
}
@Preview(showBackground = true, widthDp = 360, name = "normal")
@Preview(showBackground = true, widthDp = 360, name = "normal night", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 320, name = "small width")
@Composable
private fun PreviewV2() {
TangemThemePreviewRedesign {
CompositionLocalProvider(LocalRedesignEnabled provides true) {
Column(Modifier.background(TangemTheme.colors2.surface.level3)) {
repeat(20) {
MarketsListItemPlaceholder()
}
}
}
}
}

View file

@ -38,8 +38,8 @@ import com.tangem.utils.StringsSigns.MINUS
import kotlin.random.Random
@Composable
fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketsListItemContent(
fun MarketsListItemV1(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketsListItemContentV1(
modifier = modifier
.fillMaxWidth()
.clip(RectangleShape)
@ -50,7 +50,7 @@ fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onC
}
@Composable
private fun MarketsListItemContent(model: MarketsListItemUM, modifier: Modifier = Modifier) {
private fun MarketsListItemContentV1(model: MarketsListItemUM, modifier: Modifier = Modifier) {
val windowSize = LocalWindowSize.current
Row(
@ -273,11 +273,11 @@ private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::cl
}
Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) {
MarketsListItem(
MarketsListItemV1(
modifier = Modifier,
model = state1,
)
MarketsListItem(
MarketsListItemV1(
modifier = Modifier,
model = state2,
)

View file

@ -16,7 +16,7 @@ class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvide
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = "",
ratingPosition = "10",
ratingPosition = "1",
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
@ -33,7 +33,7 @@ class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvide
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
ratingPosition = "2",
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",

View file

@ -41,7 +41,6 @@ dependencies {
implementation(projects.domain.transaction.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.onramp.models)
implementation(projects.domain.promo.models)
implementation(projects.domain.common)
implementation(tangemDeps.card.core)

View file

@ -1,48 +0,0 @@
package com.tangem.common.ui.swapStoriesScreen
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.promo.models.StoryContent
import kotlinx.collections.immutable.persistentListOf
object SwapStoriesFactory {
// WARNING! Be careful with indices. Temporary solution.
// Use all data from v1/stories api (image url, title, subtitle)
@Suppress("MagicNumber")
fun createStoriesState(swapStory: StoryContent, onStoriesClose: (Int) -> Unit): SwapStoriesUM {
val storyOrderedImageUrls = swapStory.getImageUrls()
if (storyOrderedImageUrls.size != 5) return SwapStoriesUM.Empty
return SwapStoriesUM.Content(
stories = persistentListOf(
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[0],
title = resourceReference(R.string.swap_story_first_title),
subtitle = resourceReference(R.string.swap_story_first_subtitle),
),
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[1],
title = resourceReference(R.string.swap_story_second_title),
subtitle = resourceReference(R.string.swap_story_second_subtitle),
),
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[2],
title = resourceReference(R.string.swap_story_third_title),
subtitle = resourceReference(R.string.swap_story_third_subtitle),
),
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[3],
title = resourceReference(R.string.swap_story_forth_title),
subtitle = resourceReference(R.string.swap_story_forth_subtitle),
),
SwapStoriesUM.Content.Config(
imageUrl = storyOrderedImageUrls[4],
title = resourceReference(R.string.swap_story_fifth_title),
subtitle = resourceReference(R.string.swap_story_fifth_subtitle),
),
),
onClose = onStoriesClose,
)
}
}

View file

@ -6,8 +6,12 @@ import androidx.compose.animation.core.tween
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
/**
@ -18,6 +22,23 @@ import com.tangem.core.ui.res.TangemTheme
*/
@Composable
fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) {
if (LocalRedesignEnabled.current) {
TokenPriceTextV2(
price = price,
modifier = modifier,
priceChangeType = priceChangeType,
)
} else {
TokenPriceTextV1(
price = price,
modifier = modifier,
priceChangeType = priceChangeType,
)
}
}
@Composable
private fun TokenPriceTextV1(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) {
val growColor = TangemTheme.colors.text.accent
val fallColor = TangemTheme.colors.text.warning
val generalColor = TangemTheme.colors.text.primary1
@ -52,4 +73,60 @@ fun TokenPriceText(price: String, modifier: Modifier = Modifier, priceChangeType
style = TangemTheme.typography.body2,
overflow = TextOverflow.Visible,
)
}
@Composable
private fun TokenPriceTextV2(price: String, modifier: Modifier = Modifier, priceChangeType: PriceChangeType? = null) {
val growColor = TangemTheme.colors2.text.status.accent
val fallColor = TangemTheme.colors2.text.status.warning
val generalColor = TangemTheme.colors2.text.neutral.primary
val decimalColor = TangemTheme.colors2.text.neutral.secondary
val color = remember(generalColor) { Animatable(generalColor) }
var isAnimationSkipped by remember { mutableStateOf(false) }
LaunchedEffect(price) {
if (!isAnimationSkipped) {
isAnimationSkipped = true
return@LaunchedEffect
}
if (priceChangeType != null) {
val nextColor = when (priceChangeType) {
PriceChangeType.UP -> growColor
PriceChangeType.DOWN -> fallColor
PriceChangeType.NEUTRAL -> return@LaunchedEffect
}
color.animateTo(nextColor, snap())
color.animateTo(generalColor, tween(durationMillis = 500))
}
}
val annotatedText = remember(price) {
buildAnnotatedString {
val dotIndex = price.indexOf(".")
if (dotIndex == -1) {
append(price)
} else {
append(price.take(dotIndex))
withStyle(
style = SpanStyle(color = decimalColor),
) {
append(price.substring(dotIndex))
}
}
}
}
Text(
modifier = modifier,
text = annotatedText,
color = color.value,
maxLines = 1,
style = TangemTheme.typography2.bodySemibold16,
overflow = TextOverflow.Visible,
)
}

View file

@ -6,13 +6,11 @@ import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.abtests.manager.impl.AmplitudeABTestsManager
import com.tangem.core.abtests.manager.impl.StubABTestsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -24,7 +22,7 @@ internal object ABTestsManagerModule {
fun provideABTestsManager(
application: Application,
environmentConfig: EnvironmentConfig,
dispatchers: CoroutineDispatcherProvider,
appScope: AppCoroutineScope,
): ABTestsManager {
return if (BuildConfig.AB_TESTS_ENABLED) {
StubABTestsManager()
@ -32,7 +30,7 @@ internal object ABTestsManagerModule {
AmplitudeABTestsManager(
application = application,
apiKey = environmentConfig.amplitudeApiKey,
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
scope = appScope,
)
}
}

View file

@ -7,14 +7,14 @@ import com.amplitude.experiment.ExperimentConfig
import com.amplitude.experiment.ExperimentUser
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.analytics.models.AnalyticsParam
import kotlinx.coroutines.CoroutineScope
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
internal class AmplitudeABTestsManager(
val application: Application,
val apiKey: String,
val scope: CoroutineScope,
val scope: AppCoroutineScope,
) : ABTestsManager {
private lateinit var client: ExperimentClient

View file

@ -52,6 +52,10 @@ sealed class MainScreenAnalyticsEvent(
event = "Notice - Limits Info",
)
class ButtonQrScan : MainScreenAnalyticsEvent(
event = "Button - QR Scan",
)
class ButtonExplore : MainScreenAnalyticsEvent(
event = "Button - Explore",
)

View file

@ -1,6 +1,4 @@
import com.squareup.kotlinpoet.*
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import org.json.JSONArray
import com.tangem.plugin.configuration.configurations.TogglesGenerator
plugins {
alias(deps.plugins.android.library)
@ -11,10 +9,31 @@ plugins {
id("configuration")
}
buildscript {
dependencies {
classpath("com.squareup:kotlinpoet:1.15.0")
classpath("org.json:json:20231013")
abstract class GenerateTogglesTask : DefaultTask() {
@get:InputFiles
abstract val configFiles: ConfigurableFileCollection
@get:Input
abstract val fileNames: ListProperty<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val files = configFiles.files.toList()
val names = fileNames.get()
require(files.size == names.size) {
"configFiles (${files.size}) and fileNames (${names.size}) must have the same size"
}
files.zip(names).forEach { (inputFile, fileName) ->
require(inputFile.exists()) { "Config file not found: ${inputFile.absolutePath}" }
logger.lifecycle("Generating toggles from ${inputFile.name}")
TogglesGenerator.generate(inputFile, outputDir.get().asFile, fileName)
}
}
}
@ -23,8 +42,20 @@ android {
sourceSets["main"].java.srcDir("build/generated/source/toggles")
}
/** Config file to generated enum class name mapping */
val toggles = mapOf(
file("src/main/assets/configs/feature_toggles_config.json") to "FeatureToggles",
file("src/main/assets/configs/excluded_blockchains_config.json") to "ExcludedBlockchainToggles",
)
val generateToggles = tasks.register<GenerateTogglesTask>("generateToggles") {
configFiles.from(toggles.keys)
fileNames.set(toggles.values.toList())
outputDir.set(layout.buildDirectory.dir("generated/source/toggles"))
}
tasks.named("preBuild") {
dependsOn(generateFeatureToggles, generateExcludedBlockchainToggles)
dependsOn(generateToggles)
}
tasks.withType<Test>().configureEach {
@ -51,77 +82,4 @@ dependencies {
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
}
val generateFeatureToggles by tasks.registering {
generateToggles(
inputFilePath = "src/main/assets/configs/feature_toggles_config.json",
generatedFileName = "FeatureToggles",
)
}
val generateExcludedBlockchainToggles by tasks.registering {
generateToggles(
inputFilePath = "src/main/assets/configs/excluded_blockchains_config.json",
generatedFileName = "ExcludedBlockchainToggles",
)
}
fun Task.generateToggles(inputFilePath: String, generatedFileName: String) {
val inputFile = file(inputFilePath)
val outputDir = file("build/generated/source/toggles")
inputs.file(inputFile)
outputs.dir(outputDir)
doLast {
val jsonText = inputFile.readText()
val jsonArray = JSONArray(jsonText)
val entries = (0 until jsonArray.length()).map { i ->
val obj = jsonArray.getJSONObject(i)
val name = obj.getString("name")
val version = obj.getString("version")
CodeBlock.of("%S to %S", name, version)
}
val mapInitializer = CodeBlock.builder()
.add("mapOf(\n")
.indent()
.apply {
entries.forEach { entry ->
add(entry)
add(",\n")
}
}
.unindent()
.add(")")
.build()
val objectBuilder = TypeSpec.objectBuilder(name = generatedFileName)
.addKdoc("Generated from $inputFilePath")
.addProperty(
PropertySpec.builder("values", MAP.parameterizedBy(STRING, STRING))
.initializer(mapInitializer)
.build()
)
val fileSpec = FileSpec.builder(packageName = "com.tangem.core.configtoggle", fileName = generatedFileName)
.addType(objectBuilder.build())
.build()
val outputPackageDir = File(outputDir, "")
outputPackageDir.mkdirs()
fileSpec.writeTo(outputPackageDir)
// Remove redundant public visibility modifiers
val generatedFile = File(outputPackageDir, "com/tangem/core/configtoggle/$generatedFileName.kt")
if (generatedFile.exists()) {
val content = generatedFile.readText()
val fixedContent = content
.replace("public object ", "object ")
.replace("public val ", "val ")
generatedFile.writeText(fixedContent)
}
}
}

View file

@ -1,8 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>Indentation:ExcludedBlockchainToggles.kt$ExcludedBlockchainToggles$ </ID>
<ID>Indentation:FeatureToggles.kt$FeatureToggles$ </ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -59,5 +59,21 @@
{
"name": "CUSTOMER_IO_ENABLED",
"version": "5.35"
},
{
"name": "MAIN_SCREEN_QR_SCANNING_ENABLED",
"version": "undefined"
},
{
"name": "NEW_PROMO_BANNERS_ENABLED",
"version": "undefined"
},
{
"name": "VIRTUAL_ACCOUNTS_ENABLED",
"version": "undefined"
},
{
"name": "TOKEN_SYNC_ENABLED",
"version": "undefined"
}
]

View file

@ -1,7 +1,7 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.provider.ExcludedBlockchainTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.utils.toTableString
@ -12,11 +12,13 @@ import kotlin.properties.Delegates
/**
* [MutableExcludedBlockchainsManager] implementation in dev or mocked build
*
* @property versionProvider application version provider
* @property localTogglesStorage local storage for blockchain toggles
* @property versionProvider application version provider
* @property excludedBlockchainTogglesProvider provider for excluded blockchain toggle entries
* @property localTogglesStorage local storage for blockchain toggles
*/
internal class DevExcludedBlockchainsManager(
private val versionProvider: VersionProvider,
private val excludedBlockchainTogglesProvider: ExcludedBlockchainTogglesProvider,
private val localTogglesStorage: LocalTogglesStorage,
) : MutableExcludedBlockchainsManager {
@ -63,6 +65,7 @@ internal class DevExcludedBlockchainsManager(
private fun getFileBlockchainToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return ExcludedBlockchainToggles.values.defineTogglesAvailability(appVersion = appVersion)
return excludedBlockchainTogglesProvider.getToggles()
.defineTogglesAvailability(appVersion = appVersion)
}
}

View file

@ -1,17 +1,19 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.provider.ExcludedBlockchainTogglesProvider
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.version.VersionProvider
/**
* [ExcludedBlockchainsManager] implementation in PROD build
*
* @property versionProvider application version provider
* @property versionProvider application version provider
* @property excludedBlockchainTogglesProvider provider for excluded blockchain toggle entries
*/
internal class ProdExcludedBlockchainsManager(
private val versionProvider: VersionProvider,
private val excludedBlockchainTogglesProvider: ExcludedBlockchainTogglesProvider,
) : ExcludedBlockchainsManager {
override val excludedBlockchainsIds: Set<String> = getBlockchainToggles()
@ -19,7 +21,7 @@ internal class ProdExcludedBlockchainsManager(
private fun getBlockchainToggles(): Set<String> {
val appVersion = versionProvider.get()
return ExcludedBlockchainToggles.values
return excludedBlockchainTogglesProvider.getToggles()
.defineTogglesAvailability(appVersion = appVersion)
.filterValues { !it }
.keys

View file

@ -0,0 +1,15 @@
package com.tangem.core.configtoggle.blockchain.provider
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import javax.inject.Inject
/**
* Default implementation of [ExcludedBlockchainTogglesProvider] that reads from the generated
* [ExcludedBlockchainToggles] enum.
*/
internal class DefaultExcludedBlockchainTogglesProvider @Inject constructor() : ExcludedBlockchainTogglesProvider {
override fun getToggles(): Map<String, String> {
return ExcludedBlockchainToggles.entries.associate { it.rawName to it.version }
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.core.configtoggle.blockchain.provider
/**
* Provider for excluded blockchain toggle entries.
* This interface allows for easier testing by enabling mock implementations.
*/
internal interface ExcludedBlockchainTogglesProvider {
/**
* Returns a map of blockchain IDs to their version strings.
* The version string is "undefined" for disabled toggles, or a version number like "5.21.0" for enabled ones.
*/
fun getToggles(): Map<String, String>
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.configtoggle.BuildConfig
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.impl.DevExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.impl.ProdExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.provider.DefaultExcludedBlockchainTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.DefaultVersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -26,17 +27,22 @@ internal object ExcludedBlockchainsManagerModule {
appPreferencesStore: AppPreferencesStore,
): ExcludedBlockchainsManager {
val versionProvider = DefaultVersionProvider(context)
val excludedBlockchainTogglesProvider = DefaultExcludedBlockchainTogglesProvider()
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevExcludedBlockchainsManager(
versionProvider = versionProvider,
excludedBlockchainTogglesProvider = excludedBlockchainTogglesProvider,
localTogglesStorage = LocalTogglesStorage(
appPreferencesStore = appPreferencesStore,
preferencesKey = LocalTogglesStorage.EXCLUDED_BLOCKCHAINS_KEY,
),
)
} else {
ProdExcludedBlockchainsManager(versionProvider = versionProvider)
ProdExcludedBlockchainsManager(
versionProvider = versionProvider,
excludedBlockchainTogglesProvider = excludedBlockchainTogglesProvider,
)
}
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.configtoggle.BuildConfig
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.feature.provider.DefaultFeatureTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.DefaultVersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -26,17 +27,22 @@ internal object FeatureTogglesManagerModule {
appPreferencesStore: AppPreferencesStore,
): FeatureTogglesManager {
val versionProvider = DefaultVersionProvider(context)
val featureTogglesProvider = DefaultFeatureTogglesProvider()
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevFeatureTogglesManager(
versionProvider = versionProvider,
featureTogglesProvider = featureTogglesProvider,
featureTogglesLocalStorage = LocalTogglesStorage(
appPreferencesStore = appPreferencesStore,
preferencesKey = LocalTogglesStorage.FEATURE_TOGGLES_KEY,
),
)
} else {
ProdFeatureTogglesManager(versionProvider = versionProvider)
ProdFeatureTogglesManager(
versionProvider = versionProvider,
featureTogglesProvider = featureTogglesProvider,
)
}
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.core.configtoggle.feature
import com.tangem.core.configtoggle.FeatureToggles
/**
* Component for getting information about the availability of feature toggles
*
@ -7,6 +9,6 @@ package com.tangem.core.configtoggle.feature
*/
interface FeatureTogglesManager {
/** Check feature toggle availability by name [name] */
fun isFeatureEnabled(name: String): Boolean
/** Check feature [toggle] availability */
fun isFeatureEnabled(toggle: FeatureToggles): Boolean
}

View file

@ -3,6 +3,7 @@ package com.tangem.core.configtoggle.feature.impl
import androidx.annotation.VisibleForTesting
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.utils.toTableString
@ -14,10 +15,12 @@ import kotlin.properties.Delegates
* Feature toggles manager implementation in dev or mocked build
*
* @property versionProvider application version provider
* @property featureTogglesProvider provider for feature toggle entries
* @property featureTogglesLocalStorage local storage for feature toggles
*/
internal class DevFeatureTogglesManager(
private val versionProvider: VersionProvider,
private val featureTogglesProvider: FeatureTogglesProvider,
private val featureTogglesLocalStorage: LocalTogglesStorage,
) : MutableFeatureTogglesManager {
@ -36,7 +39,10 @@ internal class DevFeatureTogglesManager(
.toMutableMap()
}
override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap[name] == true
override fun isFeatureEnabled(toggle: FeatureToggles): Boolean = featureTogglesMap[toggle.rawName] == true
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun isFeatureEnabledByName(name: String): Boolean = featureTogglesMap[name] == true
override fun getFeatureToggles(): Map<String, Boolean> = featureTogglesMap
@ -60,7 +66,8 @@ internal class DevFeatureTogglesManager(
private fun getFileFeatureToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion)
return featureTogglesProvider.getToggles()
.defineTogglesAvailability(appVersion = appVersion)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)

View file

@ -3,26 +3,33 @@ package com.tangem.core.configtoggle.feature.impl
import androidx.annotation.VisibleForTesting
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.version.VersionProvider
/**
* Feature toggles manager implementation in PROD build
*
* @property versionProvider application version provider
* @property versionProvider application version provider
* @property featureTogglesProvider provider for feature toggle entries
*/
internal class ProdFeatureTogglesManager(
private val versionProvider: VersionProvider,
private val featureTogglesProvider: FeatureTogglesProvider,
) : FeatureTogglesManager {
private val featureToggles: Map<String, Boolean> = getFileFeatureToggles()
override fun isFeatureEnabled(name: String): Boolean = featureToggles[name] == true
override fun isFeatureEnabled(toggle: FeatureToggles): Boolean = featureToggles[toggle.rawName] == true
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun isFeatureEnabledByName(name: String): Boolean = featureToggles[name] == true
private fun getFileFeatureToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion)
return featureTogglesProvider.getToggles()
.defineTogglesAvailability(appVersion = appVersion)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)

View file

@ -0,0 +1,14 @@
package com.tangem.core.configtoggle.feature.provider
import com.tangem.core.configtoggle.FeatureToggles
import javax.inject.Inject
/**
* Default implementation of [FeatureTogglesProvider] that reads from the generated [FeatureToggles] enum.
*/
internal class DefaultFeatureTogglesProvider @Inject constructor() : FeatureTogglesProvider {
override fun getToggles(): Map<String, String> {
return FeatureToggles.entries.associate { it.rawName to it.version }
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.core.configtoggle.feature.provider
/**
* Provider for feature toggle entries.
* This interface allows for easier testing by enabling mock implementations.
*/
internal interface FeatureTogglesProvider {
/**
* Returns a map of toggle names to their version strings.
* The version string is "undefined" for disabled toggles, or a version number like "1.0.0" for enabled ones.
*/
fun getToggles(): Map<String, String>
}

View file

@ -1,7 +1,7 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.blockchain.provider.ExcludedBlockchainTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
@ -12,42 +12,74 @@ import org.junit.jupiter.api.*
internal class DevExcludedBlockchainsManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val excludedBlockchainTogglesProvider = mockk<ExcludedBlockchainTogglesProvider>()
private val localTogglesStorage = mockk<LocalTogglesStorage>(relaxUnitFun = true)
private val testVersion = "5.30"
private val testToggles = mapOf(
"ENABLED_BLOCKCHAIN" to "5.21.0",
"DISABLED_BLOCKCHAIN" to "undefined",
"FUTURE_BLOCKCHAIN" to "6.0.0",
)
private fun getExpectedFileToggles(appVersion: String?): Map<String, Boolean> {
if (appVersion.isNullOrEmpty()) {
return testToggles.mapValues { false }
}
return testToggles.mapValues { (_, version) ->
version != "undefined" && isVersionSufficient(appVersion, version)
}
}
private fun getExcludedBlockchains(appVersion: String?): Set<String> {
return getExpectedFileToggles(appVersion).filterValues { !it }.keys
}
private fun isVersionSufficient(appVersion: String, requiredVersion: String): Boolean {
val appParts = appVersion.split(".").mapNotNull { it.toIntOrNull() }
val reqParts = requiredVersion.split(".").mapNotNull { it.toIntOrNull() }
for (i in 0 until maxOf(appParts.size, reqParts.size)) {
val app = appParts.getOrElse(i) { 0 }
val req = reqParts.getOrElse(i) { 0 }
if (app > req) return true
if (app < req) return false
}
return true
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val toggles = mapOf("CHAIN_1" to "1.0.0", "CHAIN_2" to "2.0.0")
mockkObject(ExcludedBlockchainToggles)
every { ExcludedBlockchainToggles.values } returns toggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(ExcludedBlockchainToggles)
@BeforeEach
fun setupEach() {
every { excludedBlockchainTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, localTogglesStorage)
clearMocks(versionProvider, excludedBlockchainTogglesProvider, localTogglesStorage)
}
@Test
fun `successfully initialize manager`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedToggles = mapOf("CHAIN_1" to false, "CHAIN_2" to true)
val appVersion = testVersion
val fileToggles = getExpectedFileToggles(appVersion)
val savedToggles = fileToggles.mapValues { !it.value }
every { versionProvider.get() } returns appVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns savedToggles
// Act
val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds
val actual = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_1")
val expected = savedToggles.filterValues { !it }.keys
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder {
@ -59,14 +91,20 @@ internal class DevExcludedBlockchainsManagerTest {
@Test
fun `successfully initialize manager if versionProvider returns null`() = runTest {
// Arrange
every { versionProvider.get() } returns null
val appVersion: String? = null
every { versionProvider.get() } returns appVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
// Act
val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds
val actual = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
).excludedBlockchainsIds
// Assert
Truth.assertThat(actual).containsExactly("CHAIN_1", "CHAIN_2")
val expected = getExcludedBlockchains(appVersion)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder {
versionProvider.get()
@ -77,14 +115,20 @@ internal class DevExcludedBlockchainsManagerTest {
@Test
fun `successfully initialize manager if storage returns empty map`() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val appVersion = testVersion
every { versionProvider.get() } returns appVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
// Act
val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds
val actual = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
).excludedBlockchainsIds
// Assert
Truth.assertThat(actual).containsExactly("CHAIN_2")
val expected = getExcludedBlockchains(appVersion)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder {
versionProvider.get()
@ -95,13 +139,19 @@ internal class DevExcludedBlockchainsManagerTest {
@Test
fun `failure initialize manager if storage throws exception`() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val appVersion = testVersion
val exception = Exception("Test exception")
every { versionProvider.get() } returns appVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } throws exception
// Act
val actual = runCatching { DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) }
.exceptionOrNull()!!
val actual = runCatching {
DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
)
}.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
@ -118,21 +168,34 @@ internal class DevExcludedBlockchainsManagerTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ExcludeBlockchain {
@BeforeEach
fun setupEach() {
every { excludedBlockchainTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, excludedBlockchainTogglesProvider, localTogglesStorage)
}
@Test
fun excludeBlockchain_changesStatusAndSaves() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
every { versionProvider.get() } returns testVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
manager.excludeBlockchain("CHAIN_1", false)
val manager = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
)
// Act
manager.excludeBlockchain("ENABLED_BLOCKCHAIN", false)
val actual = manager.excludedBlockchainsIds
// Assert
Truth.assertThat(actual).contains("CHAIN_1")
coVerify { localTogglesStorage.store(match { it["CHAIN_1"] == false }) }
Truth.assertThat(actual).contains("ENABLED_BLOCKCHAIN")
coVerify { localTogglesStorage.store(match { it["ENABLED_BLOCKCHAIN"] == false }) }
}
}
@ -140,13 +203,26 @@ internal class DevExcludedBlockchainsManagerTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsMatchLocalConfig {
@BeforeEach
fun setupEach() {
every { excludedBlockchainTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, excludedBlockchainTogglesProvider, localTogglesStorage)
}
@Test
fun isMatchLocalConfig_returnsTrueIfMatchesFile() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
every { versionProvider.get() } returns testVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
val manager = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
)
// Act
val actual = manager.isMatchLocalConfig()
@ -158,13 +234,17 @@ internal class DevExcludedBlockchainsManagerTest {
@Test
fun isMatchLocalConfig_returnsFalseIfDiffersFromFile() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
every { versionProvider.get() } returns testVersion
val fileToggles = getExpectedFileToggles(testVersion)
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
manager.excludeBlockchain("CHAIN_1", false)
val manager = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
)
// Act
manager.excludeBlockchain("ENABLED_BLOCKCHAIN", !fileToggles.getValue("ENABLED_BLOCKCHAIN"))
val actual = manager.isMatchLocalConfig()
// Assert
@ -176,28 +256,36 @@ internal class DevExcludedBlockchainsManagerTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class RecoverLocalConfig {
@BeforeEach
fun setupEach() {
every { excludedBlockchainTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, excludedBlockchainTogglesProvider, localTogglesStorage)
}
@Test
fun recoverLocalConfig_resetsToFileAndSaves() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val toggles = mapOf("CHAIN_1" to "2.0.0", "CHAIN_2" to "2.0.0")
mockkObject(ExcludedBlockchainToggles)
every { ExcludedBlockchainToggles.values } returns toggles
coEvery { localTogglesStorage.getSyncOrEmpty() } returns mapOf("CHAIN_1" to true)
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
manager.recoverLocalConfig()
val appVersion = testVersion
every { versionProvider.get() } returns appVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
localTogglesStorage,
)
manager.excludeBlockchain("ENABLED_BLOCKCHAIN", false)
// Act
manager.recoverLocalConfig()
val actual = manager.excludedBlockchainsIds
// Assert
Truth.assertThat(actual).containsExactly("CHAIN_1", "CHAIN_2")
unmockkObject(ExcludedBlockchainToggles)
clearMocks(versionProvider, localTogglesStorage)
val expected = getExcludedBlockchains(appVersion)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
}
}
}

View file

@ -1,9 +1,12 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.blockchain.provider.ExcludedBlockchainTogglesProvider
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import io.mockk.clearMocks
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
@ -11,39 +14,65 @@ import org.junit.jupiter.api.*
internal class ProdExcludedBlockchainsManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val excludedBlockchainTogglesProvider = mockk<ExcludedBlockchainTogglesProvider>()
private val testVersion = "5.30"
private val testToggles = mapOf(
"ENABLED_BLOCKCHAIN" to "5.21.0",
"DISABLED_BLOCKCHAIN" to "undefined",
"FUTURE_BLOCKCHAIN" to "6.0.0",
)
private fun getExcludedBlockchains(appVersion: String?): Set<String> {
if (appVersion.isNullOrEmpty()) {
return testToggles.keys
}
return testToggles.filterValues { version ->
version == "undefined" || !isVersionSufficient(appVersion, version)
}.keys
}
private fun isVersionSufficient(appVersion: String, requiredVersion: String): Boolean {
val appParts = appVersion.split(".").mapNotNull { it.toIntOrNull() }
val reqParts = requiredVersion.split(".").mapNotNull { it.toIntOrNull() }
for (i in 0 until maxOf(appParts.size, reqParts.size)) {
val app = appParts.getOrElse(i) { 0 }
val req = reqParts.getOrElse(i) { 0 }
if (app > req) return true
if (app < req) return false
}
return true
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val toggles = mapOf("CHAIN_1" to "1.0.0", "CHAIN_2" to "2.0.0")
mockkObject(ExcludedBlockchainToggles)
every { ExcludedBlockchainToggles.values } returns toggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(ExcludedBlockchainToggles)
@BeforeEach
fun setupEach() {
every { excludedBlockchainTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider)
clearMocks(versionProvider, excludedBlockchainTogglesProvider)
}
@Test
fun `successfully initialize excluded blockchains`() = runTest {
// Arrange
val appVersion = "1.0.0"
val appVersion = testVersion
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds
val actual = ProdExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_2")
val expected = getExcludedBlockchains(appVersion)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder { versionProvider.get() }
@ -52,13 +81,17 @@ internal class ProdExcludedBlockchainsManagerTest {
@Test
fun `all blockchains excluded if versionProvider returns null`() = runTest {
// Arrange
every { versionProvider.get() } returns null
val appVersion: String? = null
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds
val actual = ProdExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_1", "CHAIN_2")
val expected = getExcludedBlockchains(appVersion)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder { versionProvider.get() }
@ -67,13 +100,17 @@ internal class ProdExcludedBlockchainsManagerTest {
@Test
fun `all blockchains excluded if versionProvider returns empty string`() = runTest {
// Arrange
every { versionProvider.get() } returns ""
val appVersion = ""
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds
val actual = ProdExcludedBlockchainsManager(
versionProvider,
excludedBlockchainTogglesProvider,
).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_1", "CHAIN_2")
val expected = getExcludedBlockchains(appVersion)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder { versionProvider.get() }
@ -86,7 +123,9 @@ internal class ProdExcludedBlockchainsManagerTest {
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { ProdExcludedBlockchainsManager(versionProvider) }.exceptionOrNull()!!
val actual = runCatching {
ProdExcludedBlockchainsManager(versionProvider, excludedBlockchainTogglesProvider)
}.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)

View file

@ -1,9 +1,8 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.manager.ProdFeatureTogglesManagerTest.IsFeatureEnabledModel
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.test.core.ProvideTestModels
@ -12,51 +11,55 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DevFeatureTogglesManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val featureTogglesProvider = mockk<FeatureTogglesProvider>()
private val featureTogglesLocalStorage = mockk<LocalTogglesStorage>(relaxUnitFun = true)
private val testToggles = mapOf(
"ENABLED_TOGGLE" to "1.0.0",
"DISABLED_TOGGLE" to "undefined",
)
private fun getExpectedFileToggles(appVersion: String?): Map<String, Boolean> =
testToggles.mapValues { (_, version) ->
version != "undefined" && !appVersion.isNullOrEmpty()
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0")
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(FeatureToggles)
@BeforeEach
fun setupEach() {
every { featureTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesLocalStorage)
clearMocks(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@Test
fun `successfully initialize manager`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
val fileToggles = getExpectedFileToggles(appVersion)
val savedToggles = fileToggles.mapValues { !it.value }
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
val actual = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
val expected = savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
@ -68,17 +71,21 @@ internal class DevFeatureTogglesManagerTest {
@Test
fun `successfully initialize manager if versionProvider returns null`() = runTest {
// Arrange
val appVersion = null
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
val appVersion: String? = null
val fileToggles = getExpectedFileToggles(appVersion)
val savedToggles = fileToggles.mapValues { !it.value }
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
val actual = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
val expected = savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
@ -91,16 +98,20 @@ internal class DevFeatureTogglesManagerTest {
fun `successfully initialize manager if versionProvider returns empty string`() = runTest {
// Arrange
val appVersion = ""
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
val fileToggles = getExpectedFileToggles(appVersion)
val savedToggles = fileToggles.mapValues { !it.value }
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
val actual = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
val expected = savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
@ -116,8 +127,13 @@ internal class DevFeatureTogglesManagerTest {
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) }
.exceptionOrNull()!!
val actual = runCatching {
DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
)
}.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
@ -131,16 +147,18 @@ internal class DevFeatureTogglesManagerTest {
fun `successfully initialize manager if storage returns empty map`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = emptyMap<String, Boolean>()
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
val actual = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).getFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
val expected = getExpectedFileToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
@ -153,16 +171,20 @@ internal class DevFeatureTogglesManagerTest {
fun `successfully initialize manager if storage returns unknown toggles`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = mapOf("TOGGLE_3" to true)
val fileToggles = getExpectedFileToggles(appVersion)
val unknownSavedToggles = fileToggles + ("UNKNOWN_TOGGLE" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns unknownSavedToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
val actual = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).getFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
val expected = fileToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
@ -176,13 +198,17 @@ internal class DevFeatureTogglesManagerTest {
// Arrange
val appVersion = "1.0.0"
val exception = Exception("Test exception")
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } throws exception
// Act
val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) }
.exceptionOrNull()!!
val actual = runCatching {
DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
)
}.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
@ -203,65 +229,64 @@ internal class DevFeatureTogglesManagerTest {
@BeforeAll
fun setupAll() {
every { featureTogglesProvider.getToggles() } returns testToggles
every { versionProvider.get() } returns "1.0.0"
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
val savedToggles = mapOf(
"DISABLED_TOGGLE" to true,
"ENABLED_TOGGLE" to false,
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedToggles
manager = DevFeatureTogglesManager(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@AfterAll
fun tearDownAll() {
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
clearMocks(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@ParameterizedTest
@ProvideTestModels
fun isFeatureEnabled(model: IsFeatureEnabledModel) {
// Act
val actual = manager.isFeatureEnabled(name = model.name)
val actual = manager.isFeatureEnabledByName(name = model.name)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actual).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = true),
IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "", expected = false),
IsFeatureEnabledModel(name = "ENABLED_TOGGLE", expected = false),
IsFeatureEnabledModel(name = "DISABLED_TOGGLE", expected = true),
)
}
data class IsFeatureEnabledModel(val name: String, val expected: Boolean)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsMatchLocalConfig {
@BeforeEach
fun setupEach() {
every { featureTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@ParameterizedTest
@ProvideTestModels
fun isMatchLocalConfig(model: IsMatchLocalConfigModel) = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns model.fileFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).apply {
val manager = DevFeatureTogglesManager(
versionProvider,
featureTogglesProvider,
featureTogglesLocalStorage,
).apply {
setFeatureToggles(model.storedFeatureToggles.toMutableMap())
}
@ -269,54 +294,45 @@ internal class DevFeatureTogglesManagerTest {
val actual = manager.isMatchLocalConfig()
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actual).isEqualTo(model.expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
private fun provideTestModels() = listOf(
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false),
expected = true,
),
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true),
expected = false,
),
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_1" to true),
expected = false,
),
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_3" to true, "TOGGLE_4" to false),
expected = false,
),
IsMatchLocalConfigModel(
fileFeatureToggles = emptyMap(),
storedFeatureToggles = emptyMap(),
expected = true,
),
IsMatchLocalConfigModel(
fileFeatureToggles = emptyMap(),
storedFeatureToggles = mapOf("TOGGLE_1" to true),
expected = false,
),
)
private fun provideTestModels(): List<IsMatchLocalConfigModel> {
val appVersion = "1.0.0"
val fileToggles = getExpectedFileToggles(appVersion)
val firstToggleName = "ENABLED_TOGGLE"
return listOf(
IsMatchLocalConfigModel(
storedFeatureToggles = fileToggles,
expected = true,
),
IsMatchLocalConfigModel(
storedFeatureToggles = fileToggles.mapValues { !it.value },
expected = false,
),
IsMatchLocalConfigModel(
storedFeatureToggles = mapOf(firstToggleName to fileToggles.getValue(firstToggleName)),
expected = false,
),
IsMatchLocalConfigModel(
storedFeatureToggles = mapOf("UNKNOWN_TOGGLE" to true),
expected = false,
),
IsMatchLocalConfigModel(
storedFeatureToggles = emptyMap(),
expected = false,
),
)
}
}
data class IsMatchLocalConfigModel(
val fileFeatureToggles: Map<String, String>,
val storedFeatureToggles: Map<String, Boolean>,
val expected: Boolean,
)
@ -325,41 +341,40 @@ internal class DevFeatureTogglesManagerTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetFeatureToggles {
@BeforeEach
fun setupEach() {
every { featureTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@Test
fun getFeatureToggles() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val fileFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
val appVersion = "1.0.0"
val fileToggles = getExpectedFileToggles(appVersion)
val savedToggles = mapOf(
"DISABLED_TOGGLE" to true,
"ENABLED_TOGGLE" to false,
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns fileFeatureToggles
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedToggles
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
// Act
val actual = manager.getFeatureToggles()
// Assert
val expected = savedFeatureToggles
val expected = fileToggles + savedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
}
@ -367,25 +382,30 @@ internal class DevFeatureTogglesManagerTest {
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ChangeToggle {
@BeforeEach
fun setupEach() {
every { featureTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@ParameterizedTest
@ProvideTestModels
fun changeToggle(model: ChangeToggleModel) = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns model.initialToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
// Act
manager.changeToggle(name = model.name, isEnabled = model.isEnabled)
val actual = manager.getFeatureToggles()
// Assert
val expected = model.expectedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
Truth.assertThat(actual).containsExactlyEntriesIn(model.expectedToggles)
coVerifyOrder {
versionProvider.get()
@ -395,77 +415,69 @@ internal class DevFeatureTogglesManagerTest {
featureTogglesLocalStorage.store(model.expectedToggles)
}
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
private fun provideTestModels() = listOf(
ChangeToggleModel(
initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"),
name = "TOGGLE_1",
isEnabled = false,
expectedToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false),
expectedStoreSaving = true,
),
ChangeToggleModel(
initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"),
name = "TOGGLE_2",
isEnabled = true,
expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true),
expectedStoreSaving = true,
),
ChangeToggleModel(
initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"),
name = "TOGGLE_3",
isEnabled = true,
expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false),
expectedStoreSaving = false,
),
ChangeToggleModel(
initialToggles = emptyMap(),
name = "TOGGLE_1",
isEnabled = true,
expectedToggles = emptyMap(),
expectedStoreSaving = false,
),
)
private fun provideTestModels(): List<ChangeToggleModel> {
val appVersion = "1.0.0"
val fileToggles = getExpectedFileToggles(appVersion)
val enabledName = "ENABLED_TOGGLE"
val disabledName = "DISABLED_TOGGLE"
return listOf(
ChangeToggleModel(
name = enabledName,
isEnabled = false,
expectedToggles = fileToggles + (enabledName to false),
expectedStoreSaving = true,
),
ChangeToggleModel(
name = disabledName,
isEnabled = true,
expectedToggles = fileToggles + (disabledName to true),
expectedStoreSaving = true,
),
ChangeToggleModel(
name = "UNKNOWN_TOGGLE",
isEnabled = true,
expectedToggles = fileToggles,
expectedStoreSaving = false,
),
)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class RecoverLocalConfig {
@BeforeEach
fun setupEach() {
every { featureTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
}
@Test
fun recoverLocalConfig() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val fileFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
val appVersion = "1.0.0"
val savedToggles = mapOf(
"DISABLED_TOGGLE" to true,
"ENABLED_TOGGLE" to false,
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns fileFeatureToggles
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedToggles
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesProvider, featureTogglesLocalStorage)
// Act
manager.recoverLocalConfig()
val actual = manager.getFeatureToggles()
// Assert
val expected = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
val expected = getExpectedFileToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
@ -473,14 +485,10 @@ internal class DevFeatureTogglesManagerTest {
featureTogglesLocalStorage.getSyncOrEmpty()
featureTogglesLocalStorage.store(expected)
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
}
data class ChangeToggleModel(
val initialToggles: Map<String, String>,
val name: String,
val isEnabled: Boolean,
val expectedToggles: Map<String, Boolean>,

View file

@ -1,43 +1,47 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.feature.provider.FeatureTogglesProvider
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.test.core.ProvideTestModels
import io.mockk.*
import io.mockk.clearMocks
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ProdFeatureTogglesManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val featureTogglesProvider = mockk<FeatureTogglesProvider>()
private val testToggles = mapOf(
"ENABLED_TOGGLE" to "1.0.0",
"DISABLED_TOGGLE" to "undefined",
)
private fun getExpectedToggles(appVersion: String?): Map<String, Boolean> {
return testToggles.mapValues { (_, version) ->
version != "undefined" && !appVersion.isNullOrEmpty()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0")
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(FeatureToggles)
@BeforeEach
fun setupEach() {
every { featureTogglesProvider.getToggles() } returns testToggles
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider)
clearMocks(versionProvider, featureTogglesProvider)
}
@Test
@ -47,10 +51,10 @@ internal class ProdFeatureTogglesManagerTest {
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
val actual = ProdFeatureTogglesManager(versionProvider, featureTogglesProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
val expected = getExpectedToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
@ -59,14 +63,14 @@ internal class ProdFeatureTogglesManagerTest {
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
// Arrange
val appVersion = null
val appVersion: String? = null
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
val actual = ProdFeatureTogglesManager(versionProvider, featureTogglesProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false)
val expected = getExpectedToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
@ -79,10 +83,10 @@ internal class ProdFeatureTogglesManagerTest {
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
val actual = ProdFeatureTogglesManager(versionProvider, featureTogglesProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false)
val expected = getExpectedToggles(appVersion)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
@ -95,7 +99,8 @@ internal class ProdFeatureTogglesManagerTest {
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { ProdFeatureTogglesManager(versionProvider) }.exceptionOrNull()!!
val actual = runCatching { ProdFeatureTogglesManager(versionProvider, featureTogglesProvider) }
.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
@ -113,41 +118,29 @@ internal class ProdFeatureTogglesManagerTest {
@BeforeAll
fun setupAll() {
every { featureTogglesProvider.getToggles() } returns testToggles
every { versionProvider.get() } returns "1.0.0"
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
manager = ProdFeatureTogglesManager(versionProvider)
manager = ProdFeatureTogglesManager(versionProvider, featureTogglesProvider)
}
@AfterAll
fun tearDownAll() {
clearMocks(versionProvider)
unmockkObject(FeatureToggles)
clearMocks(versionProvider, featureTogglesProvider)
}
@ParameterizedTest
@ProvideTestModels
fun isFeatureEnabled(model: IsFeatureEnabledModel) {
// Act
val actual = manager.isFeatureEnabled(name = model.name)
val actual = manager.isFeatureEnabledByName(name = model.name)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actual).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = true),
IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "", expected = false),
IsFeatureEnabledModel(name = "ENABLED_TOGGLE", expected = true),
IsFeatureEnabledModel(name = "DISABLED_TOGGLE", expected = false),
)
}

View file

@ -7,9 +7,7 @@ import com.tangem.datasource.api.common.config.ApiEnvironmentConfig
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMap
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.flow.*
/**
@ -22,7 +20,7 @@ import kotlinx.coroutines.flow.*
internal class DevApiConfigsManager(
private val apiConfigs: ApiConfigs,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
private val appScope: AppCoroutineScope,
) : MutableApiConfigsManager() {
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
@ -51,7 +49,7 @@ internal class DevApiConfigsManager(
notifyListeners(apiConfigs = apiConfigs, savedEnvironments = savedEnvironments)
}
.launchIn(CoroutineScope(SupervisorJob() + dispatchers.default))
.launchIn(appScope)
}
override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig {

View file

@ -4,10 +4,22 @@ import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.ApiEnvironmentConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.flow.*
import kotlin.Boolean
import kotlin.String
import kotlin.Unit
import kotlin.collections.Map
import kotlin.collections.any
import kotlin.collections.associateWith
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.first
import kotlin.collections.firstOrNull
import kotlin.collections.mapValues
import kotlin.collections.plus
import kotlin.error
import kotlin.to
/**
* Implementation of [ApiConfigsManager] in MOCK environment
@ -18,7 +30,7 @@ import kotlinx.coroutines.flow.*
*/
internal class MockApiConfigsManager(
private val apiConfigs: ApiConfigs,
dispatchers: CoroutineDispatcherProvider,
private val coroutineScope: AppCoroutineScope,
) : MutableApiConfigsManager() {
override val configs: StateFlow<Map<ApiConfig, ApiEnvironment>>
@ -26,8 +38,6 @@ internal class MockApiConfigsManager(
override val initializedState: StateFlow<Boolean> = MutableStateFlow(value = true)
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default)
override fun initialize() = Unit
override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig {

View file

@ -41,7 +41,8 @@ interface TangemExpressApi {
@Query("fromNetwork") fromNetwork: String,
@Query("toContractAddress") toContractAddress: String,
@Query("toNetwork") toNetwork: String,
@Query("fromAmount") fromAmount: String,
@Query("fromAmount") fromAmount: String?,
@Query("toAmount") toAmount: String? = null,
@Query("fromDecimals") fromDecimals: Int,
@Query("toDecimals") toDecimals: Int,
@Query("providerId") providerId: String,
@ -57,7 +58,8 @@ interface TangemExpressApi {
@Query("toContractAddress") toContractAddress: String,
@Query("fromAddress") fromAddress: String,
@Query("toNetwork") toNetwork: String,
@Query("fromAmount") fromAmount: String,
@Query("fromAmount") fromAmount: String?,
@Query("toAmount") toAmount: String? = null,
@Query("fromDecimals") fromDecimals: Int,
@Query("toDecimals") toDecimals: Int,
@Query("providerId") providerId: String,

View file

@ -37,8 +37,8 @@ interface TangemPayApi {
@POST("v1/deeplink/validate")
suspend fun validateDeeplink(@Body body: DeeplinkValidityRequest): ApiResponse<DeeplinkValidityResponse>
@GET("v1/customer/eligibility")
suspend fun checkCustomerEligibility(): ApiResponse<CustomerEligibilityResponse>
@GET("v1/eligibility/channels")
suspend fun getEligibilityChannels(): ApiResponse<TangemPayEligibilityChannels>
@GET("v1/order/{order_id}")
suspend fun getOrder(

View file

@ -4,11 +4,11 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CustomerEligibilityResponse(
@Json(name = "result") val result: Result?,
data class TangemPayEligibilityChannels(
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "is_tangem_pay_available") val isTangemPayAvailable: Boolean,
@Json(name = "channels") val channels: List<String>,
)
}

Some files were not shown because too many files have changed in this diff Show more