Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-10 16:15:30 +03:00
commit 2f62d482ca
635 changed files with 18802 additions and 9118 deletions

View file

@ -1,4 +1,6 @@
import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants
import com.squareup.kotlinpoet.*
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import org.json.JSONArray
plugins {
alias(deps.plugins.android.library)
@ -9,8 +11,24 @@ plugins {
id("configuration")
}
buildscript {
dependencies {
classpath("com.squareup:kotlinpoet:1.15.0")
classpath("org.json:json:20231013")
}
}
android {
namespace = "com.tangem.core.configtoggle"
sourceSets["main"].java.srcDir("build/generated/source/toggles")
}
tasks.named("preBuild") {
dependsOn(generateFeatureToggles, generateExcludedBlockchainToggles)
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
@ -32,7 +50,72 @@ dependencies {
implementation(projects.core.utils)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
}
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.forEachIndexed { index, entry ->
add(entry)
if (index != entries.lastIndex) add(",\n") else 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)
}
}

View file

@ -19,14 +19,6 @@
"name": "STAKING_CARDANO_ENABLED",
"version": "undefined"
},
{
"name": "WALLET_CONNECT_REDESIGN_ENABLED",
"version": "5.27.0"
},
{
"name": "PUSH_NOTIFICATIONS_ENABLED",
"version": "5.26.2"
},
{
"name": "USEDESK_ENABLED",
"version": "undefined"
@ -43,10 +35,6 @@
"name": "SEND_REDESIGN_ENABLED",
"version": "5.28.0"
},
{
"name": "WALLET_BALANCE_FETCHER_ENABLED",
"version": "5.27.0"
},
{
"name": "HOT_WALLET_ENABLED",
"version": "undefined"
@ -62,5 +50,17 @@
{
"name": "NEW_TOKEN_RECEIVE_ENABLED",
"version": "5.28.0"
},
{
"name": "YIELD_LENDING_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "NEW_ONRAMP_MAIN_ENABLED",
"version": "undefined"
},
{
"name": "ACCOUNTS_FEATURE_ENABLED",
"version": "undefined"
}
]

View file

@ -3,6 +3,4 @@ package com.tangem.core.configtoggle.blockchain
interface ExcludedBlockchainsManager {
val excludedBlockchainsIds: Set<String>
suspend fun init()
}

View file

@ -1,80 +0,0 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.storeObjectMap
internal class DefaultExcludedBlockchainsManager(
private val localTogglesStorage: TogglesStorage,
private val appPreferencesStore: AppPreferencesStore,
private val versionProvider: VersionProvider,
) : MutableExcludedBlockchainsManager {
private var isInitialized: Boolean = false
private lateinit var currentExcludedBlockchains: MutableMap<String, Boolean>
private lateinit var localExcludedBlockchains: Map<String, Boolean>
override val excludedBlockchainsIds: Set<String>
get() {
if (!isInitialized) error("ExcludedBlockchainsManager is not initialized")
return currentExcludedBlockchains
.filterValues { it }
.keys
}
override suspend fun init() {
localTogglesStorage.populate(path = "configs/excluded_blockchains_config")
val storedExcludedBlockchainsIds = appPreferencesStore.getObjectMapSync<Boolean>(
key = PreferencesKeys.EXCLUDED_BLOCKCHAINS_KEY,
)
localExcludedBlockchains = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get().orEmpty())
.mapValues { (_, isIncluded) -> !isIncluded }
currentExcludedBlockchains = (localExcludedBlockchains.keys + storedExcludedBlockchainsIds.keys)
.fold(mutableMapOf()) { acc, blockchainId ->
val isExcluded = storedExcludedBlockchainsIds[blockchainId] ?: localExcludedBlockchains[blockchainId]
requireNotNull(isExcluded) {
"Unable to find $blockchainId in local or stored excluded blockchains"
}
acc[blockchainId] = isExcluded
acc
}
isInitialized = true
}
override suspend fun excludeBlockchain(mainnetId: String, isExcluded: Boolean) {
currentExcludedBlockchains[mainnetId] = isExcluded
storeCurrent()
}
override fun isMatchLocalConfig(): Boolean {
return currentExcludedBlockchains == localExcludedBlockchains
}
override suspend fun recoverLocalConfig() {
currentExcludedBlockchains = localExcludedBlockchains.toMutableMap()
storeCurrent()
}
private suspend fun storeCurrent() {
appPreferencesStore.storeObjectMap(
key = PreferencesKeys.EXCLUDED_BLOCKCHAINS_KEY,
value = currentExcludedBlockchains,
)
}
}

View file

@ -0,0 +1,66 @@
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.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.utils.toTableString
import com.tangem.core.configtoggle.version.VersionProvider
import kotlinx.coroutines.runBlocking
import kotlin.properties.Delegates
/**
* [MutableExcludedBlockchainsManager] implementation in dev or mocked build
*
* @property versionProvider application version provider
* @property localTogglesStorage local storage for blockchain toggles
*/
internal class DevExcludedBlockchainsManager(
private val versionProvider: VersionProvider,
private val localTogglesStorage: LocalTogglesStorage,
) : MutableExcludedBlockchainsManager {
private val fileBlockchainToggles: Map<String, Boolean> = getFileBlockchainToggles()
private var blockchainTogglesMap: MutableMap<String, Boolean> by Delegates.notNull()
override val excludedBlockchainsIds: Set<String>
get() = blockchainTogglesMap.filterValues { !it }.keys
init {
val savedExcludedBlockchains = runBlocking { localTogglesStorage.getSyncOrEmpty() }
blockchainTogglesMap = fileBlockchainToggles
.mapValues { (blockchainId, isEnabled) ->
savedExcludedBlockchains[blockchainId] ?: isEnabled
}
.toMutableMap()
}
override suspend fun excludeBlockchain(mainnetId: String, isExcluded: Boolean) {
blockchainTogglesMap[mainnetId] = isExcluded
localTogglesStorage.store(blockchainTogglesMap)
}
override fun isMatchLocalConfig(): Boolean {
return blockchainTogglesMap == fileBlockchainToggles
}
override suspend fun recoverLocalConfig() {
blockchainTogglesMap = fileBlockchainToggles.toMutableMap()
localTogglesStorage.store(blockchainTogglesMap)
}
override fun toString(): String {
return blockchainTogglesMap
.filterKeys { it.isNotEmpty() }
.toTableString(tableName = this@DevExcludedBlockchainsManager::class.java.simpleName)
}
private fun getFileBlockchainToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return ExcludedBlockchainToggles.values.defineTogglesAvailability(appVersion = appVersion)
}
}

View file

@ -0,0 +1,27 @@
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.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.version.VersionProvider
/**
* [ExcludedBlockchainsManager] implementation in PROD build
*
* @property versionProvider application version provider
*/
internal class ProdExcludedBlockchainsManager(
private val versionProvider: VersionProvider,
) : ExcludedBlockchainsManager {
override val excludedBlockchainsIds: Set<String> = getBlockchainToggles()
private fun getBlockchainToggles(): Set<String> {
val appVersion = versionProvider.get()
return ExcludedBlockchainToggles.values
.defineTogglesAvailability(appVersion = appVersion)
.filterValues { !it }
.keys
}
}

View file

@ -3,11 +3,10 @@ package com.tangem.core.configtoggle.di
import android.content.Context
import com.tangem.core.configtoggle.BuildConfig
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.MutableExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.impl.DefaultExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.impl.DevExcludedBlockchainsManager
import com.tangem.core.configtoggle.blockchain.impl.ProdExcludedBlockchainsManager
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.DefaultVersionProvider
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.local.preferences.AppPreferencesStore
import dagger.Module
import dagger.Provides
@ -24,26 +23,20 @@ internal object ExcludedBlockchainsManagerModule {
@Singleton
fun provideExcludedBlockchainsManager(
@ApplicationContext context: Context,
assetLoader: AssetLoader,
appPreferencesStore: AppPreferencesStore,
): ExcludedBlockchainsManager {
val localTogglesStorage = LocalTogglesStorage(assetLoader)
val versionProvider = DefaultVersionProvider(context)
return DefaultExcludedBlockchainsManager(
localTogglesStorage,
appPreferencesStore,
versionProvider,
)
}
@Provides
@Singleton
fun provideMutableExcludedBlockchainsManager(
manager: ExcludedBlockchainsManager,
): MutableExcludedBlockchainsManager? {
if (!BuildConfig.TESTER_MENU_ENABLED) return null
return manager as MutableExcludedBlockchainsManager
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevExcludedBlockchainsManager(
versionProvider = versionProvider,
localTogglesStorage = LocalTogglesStorage(
appPreferencesStore = appPreferencesStore,
preferencesKey = LocalTogglesStorage.EXCLUDED_BLOCKCHAINS_KEY,
),
)
} else {
ProdExcludedBlockchainsManager(versionProvider = versionProvider)
}
}
}

View file

@ -7,14 +7,12 @@ import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.DefaultVersionProvider
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.local.preferences.AppPreferencesStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.runBlocking
import javax.inject.Singleton
@Module
@ -25,29 +23,20 @@ internal object FeatureTogglesManagerModule {
@Singleton
fun provideFeatureTogglesManager(
@ApplicationContext context: Context,
assetLoader: AssetLoader,
appPreferencesStore: AppPreferencesStore,
): FeatureTogglesManager {
val localTogglesStorage = LocalTogglesStorage(assetLoader)
val versionProvider = DefaultVersionProvider(context)
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,
appPreferencesStore = appPreferencesStore,
versionProvider = versionProvider,
featureTogglesLocalStorage = LocalTogglesStorage(
appPreferencesStore = appPreferencesStore,
preferencesKey = LocalTogglesStorage.FEATURE_TOGGLES_KEY,
),
)
} else {
ProdFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,
versionProvider = versionProvider,
)
}.also {
// We need to initialize during the hilt graph creation
// in order to provide the feature toggles correctly to other dependencies.
runBlocking {
it.init()
}
ProdFeatureTogglesManager(versionProvider = versionProvider)
}
}
}

View file

@ -7,9 +7,6 @@ package com.tangem.core.configtoggle.feature
*/
interface FeatureTogglesManager {
/** Initialize manager */
suspend fun init()
/** Check feature toggle availability by name [name] */
fun isFeatureEnabled(name: String): Boolean
}

View file

@ -1,77 +1,68 @@
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.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.utils.toTableString
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import kotlinx.coroutines.runBlocking
import kotlin.properties.Delegates
/**
* Feature toggles manager implementation in DEV build
* Feature toggles manager implementation in dev or mocked build
*
* @property localTogglesStorage local feature toggles storage
* @property appPreferencesStore application local store
* @property versionProvider application version provider
* @property featureTogglesLocalStorage local storage for feature toggles
*/
internal class DevFeatureTogglesManager(
private val localTogglesStorage: TogglesStorage,
private val appPreferencesStore: AppPreferencesStore,
private val versionProvider: VersionProvider,
private val featureTogglesLocalStorage: LocalTogglesStorage,
) : MutableFeatureTogglesManager {
private var featureTogglesMap: MutableMap<String, Boolean>? = null
private var localFeatureTogglesMap: Map<String, Boolean>? = null
private var fileFeatureTogglesMap: Map<String, Boolean> = getFileFeatureToggles()
private var featureTogglesMap: MutableMap<String, Boolean> by Delegates.notNull()
override suspend fun init() {
if (featureTogglesMap != null && localFeatureTogglesMap != null) {
return // Already initialized
}
init {
val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() }
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull<Map<String, Boolean>>(
key = PreferencesKeys.FEATURE_TOGGLES_KEY,
) ?: emptyMap<String, Boolean>()
val localFeatureToggles = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get().orEmpty())
localFeatureTogglesMap = localFeatureToggles
featureTogglesMap = localFeatureToggles
featureTogglesMap = fileFeatureTogglesMap
.mapValues { resultToggle ->
savedFeatureToggles[resultToggle.key] ?: resultToggle.value
}
.toMutableMap()
}
override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap!![name] ?: false
override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap[name] == true
override fun isMatchLocalConfig(): Boolean = featureTogglesMap == localFeatureTogglesMap
override fun getFeatureToggles(): Map<String, Boolean> = featureTogglesMap
override fun getFeatureToggles(): Map<String, Boolean> = featureTogglesMap!!
override fun isMatchLocalConfig(): Boolean = featureTogglesMap == fileFeatureTogglesMap
override suspend fun changeToggle(name: String, isEnabled: Boolean) {
featureTogglesMap!![name] ?: return
featureTogglesMap!![name] = isEnabled
appPreferencesStore.storeFeatureToggles(value = featureTogglesMap!!)
featureTogglesMap[name] ?: return
featureTogglesMap[name] = isEnabled
featureTogglesLocalStorage.store(value = featureTogglesMap)
}
override suspend fun recoverLocalConfig() {
featureTogglesMap = localFeatureTogglesMap!!.toMutableMap()
appPreferencesStore.storeFeatureToggles(value = localFeatureTogglesMap!!)
featureTogglesMap = fileFeatureTogglesMap.toMutableMap()
featureTogglesLocalStorage.store(value = fileFeatureTogglesMap)
}
override fun toString(): String {
return featureTogglesMap.toTableString(tableName = this@DevFeatureTogglesManager::class.java.simpleName)
}
private fun getFileFeatureToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun setFeatureToggles(map: MutableMap<String, Boolean>) {
featureTogglesMap = map
}
private suspend fun AppPreferencesStore.storeFeatureToggles(value: Map<String, Boolean>) {
storeObject(PreferencesKeys.FEATURE_TOGGLES_KEY, value)
}
}

View file

@ -1,41 +1,30 @@
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.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.version.VersionProvider
/**
* Feature toggles manager implementation in PROD build
*
* @property localTogglesStorage local feature toggles storage
* @property versionProvider application version provider
* @property versionProvider application version provider
*/
internal class ProdFeatureTogglesManager(
private val localTogglesStorage: TogglesStorage,
private val versionProvider: VersionProvider,
) : FeatureTogglesManager {
private var featureToggles: Map<String, Boolean>? = null
private val featureToggles: Map<String, Boolean> = getFileFeatureToggles()
override suspend fun init() {
if (featureToggles != null) {
return // Already initialized
}
override fun isFeatureEnabled(name: String): Boolean = featureToggles[name] == true
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
featureToggles = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get() ?: "")
private fun getFileFeatureToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion)
}
override fun isFeatureEnabled(name: String): Boolean = featureToggles!![name] ?: false
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun getProdFeatureToggles() = featureToggles!!
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun setProdFeatureToggles(map: Map<String, Boolean>) {
featureToggles = map
}
fun getProdFeatureToggles() = featureToggles
}

View file

@ -1,24 +1,34 @@
package com.tangem.core.configtoggle.storage
import com.tangem.datasource.asset.loader.AssetLoader
import kotlin.properties.Delegates
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.storeObjectMap
/**
* Storage implementation for storing local feature toggles.
* Feature toggles are declared in file [LOCAL_CONFIG_PATH].
* Local storage for toggles
*
* @property assetLoader asset loader
* @property appPreferencesStore app preferences store
* @property preferencesKey preferences key
*
[REDACTED_AUTHOR]
*/
internal class LocalTogglesStorage(
private val assetLoader: AssetLoader,
) : TogglesStorage {
private val appPreferencesStore: AppPreferencesStore,
private val preferencesKey: Preferences.Key<String>,
) {
override var toggles: List<ConfigToggle> by Delegates.notNull()
private set
suspend fun getSyncOrEmpty(): Map<String, Boolean> {
return appPreferencesStore.getObjectMapSync<Boolean>(key = preferencesKey)
}
override suspend fun populate(path: String) {
toggles = assetLoader.loadList<ConfigToggle>(path)
suspend fun store(value: Map<String, Boolean>) {
appPreferencesStore.storeObjectMap(key = preferencesKey, value = value)
}
companion object {
val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") }
val EXCLUDED_BLOCKCHAINS_KEY by lazy { stringPreferencesKey(name = "excludedBlockchainsV2") }
}
}

View file

@ -1,13 +1,13 @@
package com.tangem.core.configtoggle.utils
import com.tangem.core.configtoggle.storage.ConfigToggle
import com.tangem.core.configtoggle.version.VersionAvailabilityContract
internal fun List<ConfigToggle>.associateToggles(currentVersion: String): Map<String, Boolean> {
return associate { localToggle ->
Pair(
first = localToggle.name,
second = VersionAvailabilityContract(currentVersion, localToggle.version),
)
internal fun Map<String, String>.defineTogglesAvailability(appVersion: String?): Map<String, Boolean> {
return if (appVersion == null) {
mapValues { false }
} else {
mapValues { (_, version) ->
VersionAvailabilityContract(currentVersion = appVersion, localVersion = version)
}
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.core.configtoggle.utils
import java.util.Locale
internal fun Map<String, Boolean>.toTableString(tableName: String): String {
return buildString {
append("$tableName:\n")
append("|------------------------------------------|-----------|\n")
append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", "name", "isEnabled"))
append("|------------------------------------------|-----------|\n")
entries.forEachIndexed { index, (name, isEnabled) ->
append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", name, isEnabled))
}
append("|------------------------------------------|-----------|")
}
}

View file

@ -0,0 +1,203 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DevExcludedBlockchainsManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val localTogglesStorage = mockk<LocalTogglesStorage>(relaxUnitFun = 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)
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, localTogglesStorage)
}
@Test
fun `successfully initialize manager`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedToggles = mapOf("CHAIN_1" to false, "CHAIN_2" to true)
every { versionProvider.get() } returns appVersion
coEvery { localTogglesStorage.getSyncOrEmpty() } returns savedToggles
// Act
val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_1")
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder {
versionProvider.get()
localTogglesStorage.getSyncOrEmpty()
}
}
@Test
fun `successfully initialize manager if versionProvider returns null`() = runTest {
// Arrange
every { versionProvider.get() } returns null
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
// Act
val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds
// Assert
Truth.assertThat(actual).containsExactly("CHAIN_1", "CHAIN_2")
coVerifyOrder {
versionProvider.get()
localTogglesStorage.getSyncOrEmpty()
}
}
@Test
fun `successfully initialize manager if storage returns empty map`() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
// Act
val actual = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage).excludedBlockchainsIds
// Assert
Truth.assertThat(actual).containsExactly("CHAIN_2")
coVerifyOrder {
versionProvider.get()
localTogglesStorage.getSyncOrEmpty()
}
}
@Test
fun `failure initialize manager if storage throws exception`() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val exception = Exception("Test exception")
coEvery { localTogglesStorage.getSyncOrEmpty() } throws exception
// Act
val actual = runCatching { DevExcludedBlockchainsManager(versionProvider, localTogglesStorage) }
.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder {
versionProvider.get()
localTogglesStorage.getSyncOrEmpty()
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ExcludeBlockchain {
@Test
fun excludeBlockchain_changesStatusAndSaves() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
manager.excludeBlockchain("CHAIN_1", false)
// Act
val actual = manager.excludedBlockchainsIds
// Assert
Truth.assertThat(actual).contains("CHAIN_1")
coVerify { localTogglesStorage.store(match { it["CHAIN_1"] == false }) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsMatchLocalConfig {
@Test
fun isMatchLocalConfig_returnsTrueIfMatchesFile() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
// Act
val actual = manager.isMatchLocalConfig()
// Assert
Truth.assertThat(actual).isTrue()
}
@Test
fun isMatchLocalConfig_returnsFalseIfDiffersFromFile() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
coEvery { localTogglesStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevExcludedBlockchainsManager(versionProvider, localTogglesStorage)
manager.excludeBlockchain("CHAIN_1", false)
// Act
val actual = manager.isMatchLocalConfig()
// Assert
Truth.assertThat(actual).isFalse()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class RecoverLocalConfig {
@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()
// Act
val actual = manager.excludedBlockchainsIds
// Assert
Truth.assertThat(actual).containsExactly("CHAIN_1", "CHAIN_2")
unmockkObject(ExcludedBlockchainToggles)
clearMocks(versionProvider, localTogglesStorage)
}
}
}

View file

@ -0,0 +1,98 @@
package com.tangem.core.configtoggle.blockchain.impl
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.ExcludedBlockchainToggles
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ProdExcludedBlockchainsManagerTest {
private val versionProvider = mockk<VersionProvider>()
@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)
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider)
}
@Test
fun `successfully initialize excluded blockchains`() = runTest {
// Arrange
val appVersion = "1.0.0"
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_2")
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `all blockchains excluded if versionProvider returns null`() = runTest {
// Arrange
every { versionProvider.get() } returns null
// Act
val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_1", "CHAIN_2")
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `all blockchains excluded if versionProvider returns empty string`() = runTest {
// Arrange
every { versionProvider.get() } returns ""
// Act
val actual = ProdExcludedBlockchainsManager(versionProvider).excludedBlockchainsIds
// Assert
val expected = setOf("CHAIN_1", "CHAIN_2")
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `failure initialize if versionProvider throws exception`() = runTest {
// Arrange
val exception = Exception("Test exception")
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { ProdExcludedBlockchainsManager(versionProvider) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { versionProvider.get() }
}
}
}

View file

@ -0,0 +1,489 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
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.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.version.VersionProvider
import io.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 DevFeatureTogglesManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val featureTogglesLocalStorage = mockk<LocalTogglesStorage>(relaxUnitFun = true)
@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)
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesLocalStorage)
}
@Test
fun `successfully initialize manager`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@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)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `successfully initialize manager if versionProvider returns empty string`() = runTest {
// Arrange
val appVersion = ""
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `failure initialize manager if versionProvider throws exception`() = runTest {
// Arrange
val exception = Exception("Test exception")
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) }
.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { versionProvider.get() }
coVerify(inverse = true) { featureTogglesLocalStorage.getSyncOrEmpty() }
}
@Test
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
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `successfully initialize manager if storage returns unknown toggles`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = mapOf("TOGGLE_3" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `failure initialize manager if storage throws exception`() = runTest {
// 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()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsFeatureEnabled {
private lateinit var manager: DevFeatureTogglesManager
@BeforeAll
fun setupAll() {
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
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
}
@AfterAll
fun tearDownAll() {
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
@ParameterizedTest
@ProvideTestModels
fun isFeatureEnabled(model: IsFeatureEnabledModel) {
// Act
val actual = manager.isFeatureEnabled(name = model.name)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(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),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsMatchLocalConfig {
@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 {
setFeatureToggles(model.storedFeatureToggles.toMutableMap())
}
// Act
val actual = manager.isMatchLocalConfig()
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(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,
),
)
}
data class IsMatchLocalConfigModel(
val fileFeatureToggles: Map<String, String>,
val storedFeatureToggles: Map<String, Boolean>,
val expected: Boolean,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetFeatureToggles {
@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",
)
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)
// Act
val actual = manager.getFeatureToggles()
// Assert
val expected = savedFeatureToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ChangeToggle {
@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)
// Act
manager.changeToggle(name = model.name, isEnabled = model.isEnabled)
val actual = manager.getFeatureToggles()
// Assert
val expected = model.expectedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
if (model.expectedStoreSaving) {
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,
),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class RecoverLocalConfig {
@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",
)
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)
// Act
manager.recoverLocalConfig()
val actual = manager.getFeatureToggles()
// Assert
val expected = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
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>,
val expectedStoreSaving: Boolean,
)
}

View file

@ -1,237 +0,0 @@
package com.tangem.core.configtoggle.manager
import android.annotation.SuppressLint
import com.google.common.truth.Truth
import com.squareup.moshi.Moshi
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.core.configtoggle.storage.ConfigToggle
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.collections.set
/**
[REDACTED_AUTHOR]
*/
@SuppressLint("CheckResult")
internal class DevTogglesManagerTest {
private val localTogglesStorage = mockk<TogglesStorage>()
private val appPreferenceStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = mockk(relaxed = true),
)
private val versionProvider = mockk<VersionProvider>()
private val manager = DevFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,
appPreferencesStore = appPreferenceStore,
versionProvider = versionProvider,
)
@Test
fun `successfully initialize storage if shared prefs kept feature toggles`() = runTest {
val currentVersion = "0.1.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery {
appPreferenceStore.getObjectSyncOrNull<Map<String, Boolean>>(PreferencesKeys.FEATURE_TOGGLES_KEY)
} returns savedFeatureTogglesMap
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion)
.mapValues { resultToggle ->
savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value
}
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if shared prefs kept empty list`() = runTest {
val currentVersion = "0.1.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery {
appPreferenceStore.getObjectSyncOrNull<Map<String, Boolean>>(PreferencesKeys.FEATURE_TOGGLES_KEY)
} returns emptyMap()
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion)
.mapValues { resultToggle ->
savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value
}
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if shared prefs didn't keep feature toggles`() = runTest {
val currentVersion = "0.1.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery {
appPreferenceStore.getObjectSyncOrNull<Map<String, Boolean>>(PreferencesKeys.FEATURE_TOGGLES_KEY)
} returns null
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion)
.mapValues(Map.Entry<String, Boolean>::value)
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery { appPreferenceStore.getSyncOrNull(PreferencesKeys.FEATURE_TOGGLES_KEY) } returns savedFeatureToggles
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns null
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion = "")
.mapValues { resultToggle ->
savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value
}
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `get feature availability if feature toggle exists`() {
val featureToggles = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED")
Truth.assertThat(actual).isTrue()
}
@Test
fun `get feature availability if feature toggle doesn't exists`() {
val featureToggles = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "")
Truth.assertThat(actual).isFalse()
}
@Test
fun getFeatureToggles() {
val expected = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
manager.setFeatureToggles(expected)
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `change toggle that contains in map`() = runTest {
val changeableToggleName = "INACTIVE_TEST_FEATURE_ENABLED"
val resultMap = mutableMapOf(
changeableToggleName to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
manager.setFeatureToggles(resultMap)
manager.changeToggle(changeableToggleName, true)
resultMap[changeableToggleName] = true
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap)
}
@Test
fun `change toggle that doesn't contains in map`() = runTest {
val resultMap = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
manager.setFeatureToggles(resultMap)
manager.changeToggle("FEATURE_TOGGLE", true)
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap)
}
private companion object {
val savedFeatureToggles = """
[
{
"name": "INACTIVE_TEST_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "ACTIVE2_TEST_FEATURE_ENABLED",
"version": "1.0.0"
}
]
""".trimIndent()
val savedFeatureTogglesMap = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
val localFeatureToggles = listOf(
ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"),
ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"),
)
}
}

View file

@ -0,0 +1,155 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.version.VersionProvider
import io.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>()
@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)
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider)
}
@Test
fun `successfully initialize storage`() = runTest {
// Arrange
val appVersion = "1.0.0"
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
// Arrange
val appVersion = null
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `successfully initialize storage if versionProvider returns empty string`() = runTest {
// Arrange
val appVersion = ""
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `failure initialize storage if versionProvider throws exception`() = runTest {
// Arrange
val exception = Exception("Test exception")
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { ProdFeatureTogglesManager(versionProvider) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { versionProvider.get() }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsFeatureEnabled {
private lateinit var manager: ProdFeatureTogglesManager
@BeforeAll
fun setupAll() {
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)
}
@AfterAll
fun tearDownAll() {
clearMocks(versionProvider)
unmockkObject(FeatureToggles)
}
@ParameterizedTest
@ProvideTestModels
fun isFeatureEnabled(model: IsFeatureEnabledModel) {
// Act
val actual = manager.isFeatureEnabled(name = model.name)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(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),
)
}
data class IsFeatureEnabledModel(val name: String, val expected: Boolean)
}

View file

@ -1,136 +0,0 @@
package com.tangem.core.configtoggle.manager
import android.content.pm.PackageManager
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.storage.ConfigToggle
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class ProdTogglesManagerTest {
private val localTogglesStorage = mockk<TogglesStorage>()
private val versionProvider = mockk<VersionProvider>()
private val manager = ProdFeatureTogglesManager(localTogglesStorage, versionProvider)
@Test
fun `successfully initialize storage`() = runTest {
val currentVersion = "1.0.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } returns localFeatureToggles
every { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles.associateToggles(currentVersion)
Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } returns localFeatureToggles
every { versionProvider.get() } returns null
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(disabledFeatureToggles)
}
@Test
fun `failure initialize storage if localFeatureTogglesStorage throws exception`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } throws IllegalStateException(
"Property featureToggles should be initialized before get.",
)
runCatching { manager.init() }
.onSuccess { throw IllegalStateException("localFeatureToggles shouldn't be initialized") }
.onFailure {
Truth
.assertThat(it)
.hasMessageThat()
.contains("Property featureToggles should be initialized before get.")
Truth.assertThat(it).isInstanceOf(IllegalStateException::class.java)
}
coVerifyOrder { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) }
verifyAll(inverse = true) { versionProvider.get() }
}
@Test
fun `failure initialize storage if versionProvider throws exception`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } returns localFeatureToggles
every { versionProvider.get() } throws PackageManager.NameNotFoundException()
runCatching { manager.init() }
.onSuccess { throw IllegalStateException("versionProvider should throws exception") }
.onFailure {
Truth.assertThat(it).isInstanceOf(PackageManager.NameNotFoundException::class.java)
}
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
}
@Test
fun `get feature availability if feature toggle exists`() {
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setProdFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED")
Truth.assertThat(actual).isTrue()
}
@Test
fun `get feature availability if feature toggle doesn't exists`() {
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setProdFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "")
Truth.assertThat(actual).isFalse()
}
private companion object {
val localFeatureToggles = listOf(
ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"),
ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"),
)
val disabledFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
}
}

View file

@ -1,110 +0,0 @@
package com.tangem.core.configtoggle.storage
import android.annotation.SuppressLint
import com.google.common.truth.Truth
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.IOException
/**
[REDACTED_AUTHOR]
*/
@SuppressLint("CheckResult")
internal class LocalTogglesStorageTest {
private val assetReader = mockk<AssetReader>()
private val moshi = mockk<Moshi>()
private val jsonAdapter = mockk<JsonAdapter<List<ConfigToggle>>>()
// Impossible to mockk AssetLoader because it implement inline functions
private val assetLoader = AssetLoader(
assetReader = assetReader,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val storage = LocalTogglesStorage(assetLoader)
@Test
fun `successfully initialize storage`() = runTest {
everyReadingJson() returns json
everyCreatingMoshiAdapter() returns jsonAdapter
everyMappingJson() returns featureToggles
storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
coVerifyOrder {
assetReader.read(CONFIG_FILE_NAME)
jsonAdapter.fromJson(json)
}
Truth.assertThat(storage.toggles).containsExactlyElementsIn(featureToggles)
}
@Test
fun `failure initialize storage if assetReader throws exception`() = runTest {
everyReadingJson() returns json
everyCreatingMoshiAdapter() returns jsonAdapter
everyMappingJson() throws IOException()
storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
coVerifyOrder {
assetReader.read(CONFIG_FILE_NAME)
jsonAdapter.fromJson(json)
}
Truth.assertThat(storage.toggles).containsExactlyElementsIn(emptyList<ConfigToggle>())
}
@Test
fun `failure initialize storage if jsonAdapter throws exception`() = runTest {
everyReadingJson() throws IOException()
storage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
coVerifyOrder { assetReader.read(CONFIG_FILE_NAME) }
verifyAll(inverse = true) { jsonAdapter.fromJson(any<String>()) }
Truth.assertThat(storage.toggles).containsExactlyElementsIn(emptyList<ConfigToggle>())
}
private fun everyReadingJson() = coEvery { assetReader.read(CONFIG_FILE_NAME) }
private fun everyCreatingMoshiAdapter() = every {
val types = Types.newParameterizedType(List::class.java, ConfigToggle::class.java)
moshi.adapter<List<ConfigToggle>>(types)
}
private fun everyMappingJson() = every { jsonAdapter.fromJson(json) }
private companion object {
const val CONFIG_FILE_NAME = "configs/feature_toggles_config.json"
val json = """
[
{
"name": "INACTIVE_TEST_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "ACTIVE2_TEST_FEATURE_ENABLED",
"version": "1.0.0"
}
]
""".trimIndent()
val featureToggles = listOf(
ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"),
ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"),
)
}
}

View file

@ -7,34 +7,51 @@ package com.tangem.datasource.api.common.response
*/
sealed class ApiResponse<T : Any> {
/**
* Represents a successful response from the API.
*
* @property data The data returned by the API.
*/
data class Success<T : Any>(val data: T) : ApiResponse<T>()
/** Map of headers (header name with list of values) */
abstract val headers: Map<String, List<String>>
/**
* Represents an error response or failure from the API.
* Represents a successful response from the API
*
* @property cause The cause of the error.
* @property data the data returned by the API
* @property headers the headers returned by the API
*/
data class Error(val cause: ApiResponseError) : ApiResponse<Nothing>()
data class Success<T : Any>(
val data: T,
override val headers: Map<String, List<String>> = emptyMap(),
) : ApiResponse<T>()
/**
* Represents an error response or failure from the API
*
* @property cause the cause of the error
* @property headers the headers returned by the API
*/
data class Error(
val cause: ApiResponseError,
override val headers: Map<String, List<String>> = emptyMap(),
) : ApiResponse<Nothing>()
}
/**
* Wraps data in a [ApiResponse.Success] instance.
* Wraps data in a [ApiResponse.Success] instance
*
* @param data The data to wrap.
* @return A [ApiResponse.Success] instance containing the provided data.
* @param data the data to wrap
* @param headers the headers returned by the API
* @return a [ApiResponse.Success] instance containing the provided data
*/
internal fun <T : Any> apiSuccess(data: T): ApiResponse<T> = ApiResponse.Success(data)
internal fun <T : Any> apiSuccess(data: T, headers: Map<String, List<String>>): ApiResponse<T> {
return ApiResponse.Success(data, headers)
}
/**
* Wraps an [ApiResponseError] in a [ApiResponse.Error] instance.
* Wraps an [ApiResponseError] in a [ApiResponse.Error] instance
*
* @param cause The error to wrap.
* @return A [ApiResponse.Error] instance containing the provided error.
* @param cause the error to wrap
* @param headers the headers returned by the API
* @return a [ApiResponse.Error] instance containing the provided error
*/
@Suppress("UNCHECKED_CAST")
internal fun <T : Any> apiError(cause: ApiResponseError): ApiResponse<T> = ApiResponse.Error(cause) as ApiResponse<T>
internal fun <T : Any> apiError(cause: ApiResponseError, headers: Map<String, List<String>>): ApiResponse<T> {
return ApiResponse.Error(cause, headers) as ApiResponse<T>
}

View file

@ -47,7 +47,8 @@ internal class ApiResponseCallDelegate<T : Any>(
Timber.e(e, "onFailure UnknownException")
ApiResponseError.UnknownException(e)
}
val safeResponse = apiError<T>(error)
val safeResponse = apiError<T>(cause = error, headers = emptyMap())
responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse))
}

View file

@ -20,6 +20,8 @@ sealed class ApiResponseError : Exception() {
// region Error Codes
enum class Code(val numericCode: Int) {
// 3xx Server Errors
NOT_MODIFIED(numericCode = 304),
// 4xx Server Errors
BAD_REQUEST(numericCode = 400),
UNAUTHORIZED(numericCode = 401),
@ -64,10 +66,6 @@ sealed class ApiResponseError : Exception() {
;
override fun toString(): String = "$numericCode - $name"
companion object {
val values = values()
}
}
// endregion Error Codes
}

View file

@ -19,4 +19,9 @@ inline fun <T> catchApiResponseError(onError: (ApiResponseError) -> Unit, block:
onError(e)
throw e
}
}
/** Checks if the ApiResponseError is a network error with the specified HTTP status [code] */
fun ApiResponseError.isNetworkError(code: ApiResponseError.HttpException.Code): Boolean {
return this is ApiResponseError.HttpException && this.code == code
}

View file

@ -0,0 +1,3 @@
package com.tangem.datasource.api.common.response
const val IF_NONE_MATCH_HEADER = "IfNoneMatch"

View file

@ -12,12 +12,13 @@ import java.util.concurrent.TimeoutException
import javax.net.ssl.SSLHandshakeException
internal fun <T : Any> Response<T>.toSafeApiResponse(analyticsErrorHandler: AnalyticsErrorHandler): ApiResponse<T> {
val headers = headers().toMultimap()
val body = body()
return if (isSuccessful && body != null) {
apiSuccess(body)
apiSuccess(data = body, headers = headers)
} else {
val code = ApiResponseError.HttpException.Code.values
val code = ApiResponseError.HttpException.Code.entries
.firstOrNull { it.numericCode == code() }
val e = try {
if (code == null) {
@ -33,7 +34,7 @@ internal fun <T : Any> Response<T>.toSafeApiResponse(analyticsErrorHandler: Anal
ApiResponseError.UnknownException(e)
}
apiError(e)
apiError(e, headers)
}
}

View file

@ -103,4 +103,7 @@ interface TangemPayApi {
@GET("v1/customer/kyc")
suspend fun getKycAccess(@Header("Authorization") authHeader: String): ApiResponse<KycAccessInfoResponse>
@GET("v1/customer/me")
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse>
}

View file

@ -0,0 +1,48 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CustomerMeResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "id") val id: String,
@Json(name = "state") val state: String,
@Json(name = "createdAt") val createdAt: String,
@Json(name = "product_instance") val productInstance: ProductInstance,
@Json(name = "payment_account") val paymentAccount: PaymentAccount,
@Json(name = "kyc") val kyc: Kyc,
)
@JsonClass(generateAdapter = true)
data class ProductInstance(
@Json(name = "id") val id: String,
@Json(name = "cid") val cid: String,
@Json(name = "card_id") val cardId: String,
@Json(name = "card_wallet_address") val cardWalletAddress: String,
@Json(name = "status") val status: String,
@Json(name = "updated_at") val updatedAt: String,
@Json(name = "payment_account_id") val paymentAccountId: String,
)
@JsonClass(generateAdapter = true)
data class PaymentAccount(
@Json(name = "id") val id: String,
@Json(name = "address") val address: String,
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
)
@JsonClass(generateAdapter = true)
data class Kyc(
@Json(name = "id") val id: String,
@Json(name = "provider") val provider: String,
@Json(name = "status") val status: String,
@Json(name = "risk") val risk: String,
@Json(name = "review_answer") val reviewAnswer: String,
@Json(name = "created_at") val createdAt: String,
)
}

View file

@ -154,7 +154,8 @@ interface TangemTechApi {
suspend fun saveWalletAccounts(
@Path("walletId") walletId: String,
@Header("If-Match") ifMatch: String,
): ApiResponse<SaveWalletAccountsResponse>
@Body body: SaveWalletAccountsResponse,
): ApiResponse<Unit>
@GET("/v1/wallets/{walletId}/accounts/archived")
suspend fun getWalletArchivedAccounts(

View file

@ -15,7 +15,7 @@ data class GetWalletAccountsResponse(
@JsonClass(generateAdapter = true)
data class Wallet(
@Json(name = "version") val version: Int,
@Json(name = "version") val version: Int = 0,
@Json(name = "group") val group: GroupType,
@Json(name = "sort") val sort: SortType,
@Json(name = "totalAccounts") val totalAccounts: Int,

View file

@ -14,11 +14,18 @@ interface RuntimeStateStore<T> {
/** Get flow of elements [T] */
fun get(): StateFlow<T>
/** Get element [T] synchronously or null */
suspend fun getSyncOrNull(): T?
/** Store [value] */
suspend fun store(value: T)
/** Update current value by [function] */
suspend fun update(function: (T) -> T)
/** Clear stored value */
fun clear()
companion object {
/**
@ -32,6 +39,8 @@ interface RuntimeStateStore<T> {
override fun get(): StateFlow<T> = flow
override suspend fun getSyncOrNull(): T? = flow.value
override suspend fun store(value: T) {
flow.value = value
}
@ -39,6 +48,10 @@ interface RuntimeStateStore<T> {
override suspend fun update(function: (T) -> T) {
flow.update(function)
}
override fun clear() {
flow.value = defaultValue
}
}
}
}

View file

@ -7,7 +7,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.IS_TANGEM_TOS_ACC
import com.tangem.datasource.local.preferences.PreferencesKeys.SAVE_USER_WALLETS_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_OPEN_WELCOME_ON_RESUME_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_ASK_BIOMETRY_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.USED_CARDS_INFO_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.USER_WAS_INTERACT_WITH_RATING_KEY
@ -26,7 +26,7 @@ object PreferencesKeys {
val SAVE_USER_WALLETS_KEY by lazy { booleanPreferencesKey(name = "saveUserWallets") }
val SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY by lazy { booleanPreferencesKey("saveUserWalletShown") }
val SHOULD_SHOW_ASK_BIOMETRY_KEY by lazy { booleanPreferencesKey("saveUserWalletShown") }
val APP_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "launchCount") }
@ -56,10 +56,6 @@ object PreferencesKeys {
val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") }
val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") }
val EXCLUDED_BLOCKCHAINS_KEY by lazy { stringPreferencesKey(name = "excludedBlockchainsV2") }
val WAS_TWINS_ONBOARDING_SHOWN by lazy { booleanPreferencesKey(name = "twinsOnboardingShown") }
val IS_TANGEM_TOS_ACCEPTED_KEY by lazy { booleanPreferencesKey(name = "tangem_tos_accepted") }
@ -187,7 +183,7 @@ object PreferencesKeys {
internal fun getTapPrefKeysToMigrate(): Set<String> {
return setOf(
SAVE_USER_WALLETS_KEY,
SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY,
SHOULD_SHOW_ASK_BIOMETRY_KEY,
APP_LAUNCH_COUNT_KEY,
SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY,
FUNDS_FOUND_DATE_KEY,

View file

@ -39,5 +39,13 @@ internal class DefaultUserTokensResponseStore(
)
}
override suspend fun clear(userWalletId: UserWalletId) {
appPreferencesStore.updateData { preferences ->
val key = createPreferencesKey(userWalletId = userWalletId.stringValue)
preferences.toMutablePreferences().apply { remove(key) }
}
}
private fun createPreferencesKey(userWalletId: String) = stringPreferencesKey(name = "user_tokens_$userWalletId")
}

View file

@ -17,4 +17,6 @@ interface UserTokensResponseStore {
suspend fun getSyncOrNull(userWalletId: UserWalletId): UserTokensResponse?
suspend fun store(userWalletId: UserWalletId, response: UserTokensResponse)
suspend fun clear(userWalletId: UserWalletId)
}

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.local.visa
interface TangemPayStorage {
suspend fun store(authHeader: String)
suspend fun get(): String?
suspend fun clear()
}

View file

@ -30,7 +30,7 @@ import com.tangem.core.ui.components.fields.SimpleDialogTextField
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.DialogTestTags
import com.tangem.core.ui.test.BaseDialogTestTags
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -219,13 +219,14 @@ private fun TangemDialog(
color = TangemTheme.colors.background.primary,
)
.padding(vertical = TangemTheme.dimens.spacing24)
.testTag(DialogTestTags.DIALOG_CONTAINER),
.testTag(BaseDialogTestTags.CONTAINER),
) {
if (title != null) {
Text(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing24)
.fillMaxWidth(),
.fillMaxWidth()
.testTag(BaseDialogTestTags.TITLE),
text = title,
style = when (type) {
is DialogType.Message -> TangemTheme.typography.h2
@ -262,7 +263,8 @@ private fun DialogContent(type: DialogType, modifier: Modifier = Modifier) {
Text(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing24)
.fillMaxWidth(),
.fillMaxWidth()
.testTag(BaseDialogTestTags.TEXT),
text = type.message,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,

View file

@ -32,6 +32,12 @@ sealed class TopAppBarButtonUM(
enabled = enabled,
)
fun Close(enabled: Boolean = true, onCloseClick: () -> Unit) = Icon(
iconRes = R.drawable.ic_close_24,
onClicked = onCloseClick,
enabled = enabled,
)
fun Text(text: TextReference, onTextClicked: () -> Unit, enabled: Boolean = true) = Text(
text = text,
onClicked = onTextClicked,

View file

@ -8,6 +8,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -20,6 +21,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.WalletConnectDetailsBottomSheetTestTags
/**
* Title component for [TangemModalBottomSheet] with [TangemIconButton] for buttons.
@ -54,7 +56,9 @@ fun TangemModalBottomSheetTitle(
text = title.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.align(Alignment.CenterHorizontally),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.testTag(WalletConnectDetailsBottomSheetTestTags.TITLE),
)
}
if (subtitle != null) {
@ -62,7 +66,9 @@ fun TangemModalBottomSheetTitle(
text = subtitle.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.align(Alignment.CenterHorizontally),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.testTag(WalletConnectDetailsBottomSheetTestTags.DATE),
)
}
}
@ -72,7 +78,8 @@ fun TangemModalBottomSheetTitle(
onClick = onEndClick,
modifier = Modifier
.padding(16.dp)
.align(Alignment.CenterEnd),
.align(Alignment.CenterEnd)
.testTag(WalletConnectDetailsBottomSheetTestTags.CLOSE_BUTTON),
)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.core.ui.components.bottomsheets.modal
import android.content.res.Configuration
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
@ -49,7 +50,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> TangemModalBottomSheetWi
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit = {},
crossinline content: @Composable (T) -> Unit,
crossinline footer: @Composable (BoxScope.(T) -> Unit),
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
) {
val isAlwaysVisible = LocalBottomSheetAlwaysVisible.current
@ -84,7 +85,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> DefaultModalBottomSheetW
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
crossinline footer: @Composable (BoxScope.(T) -> Unit),
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
) {
var isVisible by remember { mutableStateOf(value = config.isShown) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded)
@ -118,7 +119,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewModalBottomSheetW
skipPartiallyExpanded: Boolean = true,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
crossinline footer: @Composable BoxScope.(T) -> Unit,
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
) {
BasicModalBottomSheetWithFooter<T>(
config = config,
@ -145,7 +146,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
crossinline footer: @Composable (BoxScope.(T) -> Unit),
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
modifier: Modifier = Modifier,
) {
val model = config.content as? T ?: return
@ -156,8 +157,13 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
val scrollState = rememberScrollState(initial = initial)
val isKeyboardOpen by rememberIsKeyboardVisible()
val buttonHeight = TangemTheme.dimens.spacing80
val contentBottomPadding = TangemTheme.dimens.spacing80
val buttonHeight by animateDpAsState(
if (footer != null) {
80.dp
} else {
0.dp
},
)
// Offset calculation for keyboard scroll adjustment:
// 1) Button height (footer)
// 2) Column content bottom padding
@ -202,7 +208,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
Column(
modifier = Modifier
.verticalScroll(state = scrollState)
.padding(bottom = contentBottomPadding),
.padding(bottom = buttonHeight),
) {
content(model)
}
@ -218,7 +224,9 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
.height(buttonHeight)
.align(Alignment.BottomCenter),
) {
footer(model)
if (footer != null) {
footer(model)
}
}
}
}

View file

@ -24,7 +24,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.StakingSendScreenTestTags
import com.tangem.core.ui.test.SendScreenTestTags
import com.tangem.core.ui.utils.*
import java.math.BigDecimal
import java.text.DecimalFormat
@ -106,7 +106,7 @@ fun AmountTextField(
visualTransformation = visualTransformation,
modifier = Modifier
.background(backgroundColor)
.testTag(StakingSendScreenTestTags.INPUT_TEXT_FIELD),
.testTag(SendScreenTestTags.INPUT_TEXT_FIELD),
)
}
}

View file

@ -25,7 +25,6 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.utils.StringsSigns.PASSWORD_VISUAL_CHAR
@ -159,14 +158,17 @@ private fun CellDecoration(
)
}
} else {
Text(
Box(
modifier = Modifier.sizeIn(minWidth = minWidth, minHeight = minHeight),
text = text,
style = TangemTheme.typography.h3,
color = color,
textAlign = TextAlign.Center,
lineHeight = 48.sp,
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = text,
style = TangemTheme.typography.h3,
color = color,
textAlign = TextAlign.Center,
)
}
}
}
}

View file

@ -0,0 +1,104 @@
package com.tangem.core.ui.components.grid
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* EnumeratedTwoColumnGrid component
*
* @param items component items
* @param modifier composable modifier
*
*/
@Composable
fun EnumeratedTwoColumnGrid(items: ImmutableList<EnumeratedTwoColumnGridItem>, modifier: Modifier = Modifier) {
VerticalGrid(
modifier = modifier,
items = items,
) { item ->
Row(
modifier = Modifier.padding(all = TangemTheme.dimens.size8),
verticalAlignment = Alignment.CenterVertically,
) {
if (LocalLayoutDirection.current == LayoutDirection.Ltr) {
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
} else {
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
}
@Composable
private inline fun <T> VerticalGrid(
items: ImmutableList<T>,
modifier: Modifier = Modifier,
crossinline content: @Composable (T) -> Unit,
) {
val columnLength = items.size / 2
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
repeat(2) { index ->
Column {
for (i in 0 until columnLength) {
val item = items[index * columnLength + i]
content(item)
}
}
}
}
}
@Preview(widthDp = 360, heightDp = 640, showBackground = true)
@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
EnumeratedTwoColumnGrid(
items = List(24) {
EnumeratedTwoColumnGridItem(
index = it + 1,
mnemonic = "word${it + 1}",
)
}.toImmutableList(),
)
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.ui.components.grid.entity
data class EnumeratedTwoColumnGridItem(
val index: Int,
val mnemonic: String,
)

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.Alignment.Companion.CenterEnd
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -32,6 +33,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SendAddressScreenTestTags
import com.tangem.core.ui.utils.DEFAULT_ANIMATION_DURATION
import kotlinx.coroutines.delay
@ -92,6 +94,7 @@ fun InputRowRecipient(
text = it.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = color,
modifier = Modifier.testTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD_TITLE),
)
}
Box(
@ -115,7 +118,8 @@ fun InputRowRecipient(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing12)
.weight(1f)
.align(CenterVertically),
.align(CenterVertically)
.testTag(SendAddressScreenTestTags.ADDRESS_TEXT_FIELD),
)
CrossIcon(
onClick = onPasteClick,

View file

@ -27,7 +27,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
@ -63,8 +62,8 @@ fun Notification(
NotificationConfig.IconTint.Unspecified -> null
NotificationConfig.IconTint.Accent -> TangemTheme.colors.icon.accent
NotificationConfig.IconTint.Attention -> TangemTheme.colors.icon.attention
NotificationConfig.IconTint.Warning -> TangemTheme.colors.icon.warning
},
iconSize: Dp = 20.dp,
isEnabled: Boolean = true,
) {
NotificationBaseContainer(
@ -78,7 +77,7 @@ fun Notification(
MainContent(
iconResId = config.iconResId,
iconTint = iconTint,
iconSize = iconSize,
iconSize = config.iconSize,
title = config.title,
titleColor = titleColor,
subtitle = config.subtitle,
@ -117,7 +116,9 @@ internal fun NotificationBaseContainer(
) {
Box {
Column(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing12),
modifier = Modifier
.padding(all = TangemTheme.dimens.spacing12)
.testTag(NotificationTestTags.CONTAINER),
verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12),
) {
content()
@ -152,7 +153,8 @@ private fun MainContent(
tint = iconTint,
modifier = Modifier
.size(size = iconSize)
.align(alignment = Alignment.Top),
.align(alignment = Alignment.Top)
.testTag(NotificationTestTags.ICON),
)
SpacerW(width = TangemTheme.dimens.spacing10)
@ -220,7 +222,7 @@ internal fun TextsBlock(
text = subtitleText,
color = subtitleColor,
style = TangemTheme.typography.caption2,
modifier = Modifier.testTag(NotificationTestTags.TEXT),
modifier = Modifier.testTag(NotificationTestTags.MESSAGE),
)
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.core.ui.components.notifications
import androidx.annotation.DrawableRes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.TextReference
/**
@ -26,6 +28,7 @@ data class NotificationConfig(
val onCloseClick: (() -> Unit)? = null,
val showArrowIcon: Boolean = onClick != null,
val iconTint: IconTint = IconTint.Unspecified,
val iconSize: Dp = 20.dp,
) {
sealed class ButtonsState {
@ -62,5 +65,6 @@ data class NotificationConfig(
Unspecified,
Accent,
Attention,
Warning,
}
}

View file

@ -23,7 +23,7 @@ import java.util.UUID
* @param modifier modifier
*/
@Composable
internal fun TxHistoryGroupTitle(config: TxHistoryItemState.GroupTitle, modifier: Modifier = Modifier) {
fun TxHistoryGroupTitle(config: TxHistoryItemState.GroupTitle, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.background(TangemTheme.colors.background.primary)

View file

@ -0,0 +1,6 @@
package com.tangem.core.ui.test
object BaseAmountBlockTestTags {
const val PRIMARY_AMOUNT = "STAKING_SEND_DETAILS_SCREEN_PRIMARY_AMOUNT"
const val SECONDARY_AMOUNT = "TAKING_SEND_DETAILS_SCREEN_SECONDARY_AMOUNT"
}

View file

@ -0,0 +1,7 @@
package com.tangem.core.ui.test
object BaseDialogTestTags {
const val CONTAINER = "BASE_DIALOG_CONTAINER"
const val TITLE = "BASE_DIALOG_TITLE"
const val TEXT = "BASE_DIALOG_TEXT"
}

View file

@ -0,0 +1,8 @@
package com.tangem.core.ui.test
object DeviceSettingsScreenTestTags {
const val LAZY_LIST = "DEVICE_SETTINGS_SCREEN_LAZY_LIST"
const val IMAGE_BLOCK = "DEVICE_SETTINGS_SCREEN_IMAGE_BLOCK"
const val ITEM_TITLE = "DEVICE_SETTINGS_SCREEN_ITEM_TITLE"
const val ITEM_SUBTITLE = "DEVICE_SETTINGS_SCREEN_ITEM_SUBTITLE"
}

View file

@ -1,5 +0,0 @@
package com.tangem.core.ui.test
object DialogTestTags {
const val DIALOG_CONTAINER = "DIALOG_CONTAINER"
}

View file

@ -1,6 +1,8 @@
package com.tangem.core.ui.test
object NotificationTestTags {
const val CONTAINER = "NOTIFICATION_CONTAINER"
const val TITLE = "NOTIFICATION_TITLE"
const val TEXT = "NOTIFICATION_TEXT"
const val MESSAGE = "NOTIFICATION_MESSAGE"
const val ICON = "NOTIFICATION_ICON"
}

View file

@ -0,0 +1,10 @@
package com.tangem.core.ui.test
object ResetCardScreenTestTags {
const val TITLE = "RESET_CARD_SCREEN_TITLE"
const val ATTENTION_IMAGE = "RESET_CARD_SCREEN_ATTENTION_IMAGE"
const val SUBTITLE = "RESET_CARD_SCREEN_SUBTITLE"
const val DESCRIPTION = "RESET_CARD_SCREEN_DESCRIPTION"
const val CHECKBOX = "RESET_CARD_SCREEN_CHECKBOX"
const val CHECKBOX_TEXT = "RESET_CARD_SCREEN_CHECKBOX_TEXT"
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.ui.test
object SendAddressScreenTestTags {
const val ADDRESS_TEXT_FIELD_TITLE = "SEND_ADDRESS_TEXT_FIELD_TITLE"
const val ADDRESS_TEXT_FIELD = "SEND_ADDRESS_TEXT_FIELD"
}

View file

@ -0,0 +1,16 @@
package com.tangem.core.ui.test
object SendScreenTestTags {
const val SCREEN_CONTAINER = "SEND_SCREEN_CONTAINER"
const val AMOUNT_CONTAINER_TITLE = "SEND_SCREEN_AMOUNT_CONTAINER_TITLE"
const val AMOUNT_CONTAINER_TEXT = "SEND_SCREEN_AMOUNT_CONTAINER_TEXT"
const val INPUT_TEXT_FIELD = "SEND_SCREEN_INPUT_TEXT_FIELD"
const val SECONDARY_AMOUNT = "SEND_SCREEN_SECONDARY_AMOUNT"
const val CURRENCY_BUTTON = "SEND_SCREEN_CURRENCY_BUTTON"
const val FIAT_ICON = "SEND_SCREEN_FIAT_ICON"
const val CURRENCY_ICON = "SEND_SCREEN_CURRENCY_ICON"
const val MAX_BUTTON = "END_SCREEN_MAX_BUTTON"
const val PREVIOUS_BUTTON = "SEND_SCREEN_PREVIOUS_BUTTON"
}

View file

@ -1,10 +1,6 @@
package com.tangem.core.ui.test
object StakingSendDetailsScreenTestTags {
const val PRIMARY_AMOUNT = "STAKING_SEND_DETAILS_SCREEN_PRIMARY_AMOUNT"
const val SECONDARY_AMOUNT = "TAKING_SEND_DETAILS_SCREEN_SECONDARY_AMOUNT"
const val VALIDATOR_BLOCK = "TAKING_SEND_DETAILS_SCREEN_VALIDATOR_BLOCK"
const val NETWORK_FEE_BLOCK = "TAKING_SEND_DETAILS_SCREEN_NETWORK_FEE_BLOCK"
const val VALIDATOR_BLOCK = "STAKING_SEND_DETAILS_SCREEN_VALIDATOR_BLOCK"
const val NETWORK_FEE_BLOCK = "STAKING_SEND_DETAILS_SCREEN_NETWORK_FEE_BLOCK"
}

View file

@ -1,16 +0,0 @@
package com.tangem.core.ui.test
object StakingSendScreenTestTags {
const val SCREEN_CONTAINER = "STAKING_SEND_SCREEN_CONTAINER"
const val AMOUNT_CONTAINER_TITLE = "STAKING_SEND_SCREEN_AMOUNT_CONTAINER_TITLE"
const val AMOUNT_CONTAINER_TEXT = "STAKING_SEND_SCREEN_AMOUNT_CONTAINER_TEXT"
const val INPUT_TEXT_FIELD = "STAKING_SEND_SCREEN_INPUT_TEXT_FIELD"
const val SECONDARY_AMOUNT = "STAKING_SEND_SCREEN_SECONDARY_AMOUNT"
const val CURRENCY_BUTTON = "STAKING_SEND_SCREEN_CURRENCY_BUTTON"
const val FIAT_ICON = "STAKING_SEND_SCREEN_FIAT_ICON"
const val CURRENCY_ICON = "STAKING_SEND_SCREEN_CURRENCY_ICON"
const val MAX_BUTTON = "STAKING_SEND_SCREEN_MAX_BUTTON"
const val PREVIOUS_BUTTON = "STAKING_SEND_SCREEN_PREVIOUS_BUTTON"
}

View file

@ -0,0 +1,22 @@
package com.tangem.core.ui.test
object WalletConnectBottomSheetTestTags {
const val TITLE = "WALLET_CONNECT_BOTTOM_SHEET_TITLE"
const val APP_ICON = "WALLET_CONNECT_BOTTOM_SHEET_APP_ICON"
const val APP_NAME = "WALLET_CONNECT_BOTTOM_SHEET_APP_NAME"
const val APPROVE_ICON = "WALLET_CONNECT_BOTTOM_SHEET_APPROVE_ICON"
const val APP_URL = "WALLET_CONNECT_BOTTOM_SHEET_APP_URL"
const val CONNECTION_REQUEST_ICON = "WALLET_CONNECT_BOTTOM_SHEET_CONNECTION_REQUEST_ICON"
const val CONNECTION_REQUEST_TEXT = "WALLET_CONNECT_BOTTOM_SHEET_CONNECTION_REQUEST_TEXT"
const val CONNECTION_REQUEST_CHEVRON = "WALLET_CONNECT_BOTTOM_SHEET_CONNECTION_REQUEST_CHEVRON"
const val WALLET_ICON = "WALLET_CONNECT_BOTTOM_SHEET_WALLET_ICON"
const val WALLET_NAME_TITLE = "WALLET_CONNECT_BOTTOM_SHEET_WALLET_NAME_TITLE"
const val WALLET_NAME = "WALLET_CONNECT_BOTTOM_SHEET_WALLET_NAME"
const val NETWORKS_ICON = "WALLET_CONNECT_BOTTOM_SHEET_NETWORKS_ICON"
const val NETWORKS_TITLE = "WALLET_CONNECT_BOTTOM_SHEET_NETWORKS_TITLE"
const val NETWORKS_SELECTOR_ICON = "WALLET_CONNECT_BOTTOM_SHEET_NETWORKS_SELECTOR_ICON"
const val NETWORKS_ICONS = "WALLET_CONNECT_BOTTOM_SHEET_NETWORKS_ICONS"
}

View file

@ -0,0 +1,16 @@
package com.tangem.core.ui.test
object WalletConnectDetailsBottomSheetTestTags {
const val TITLE = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_TITLE"
const val DATE = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_DATE"
const val CLOSE_BUTTON = "WALLET_CONNECT_DETAILS_BOTTOM_CLOSE_BUTTON"
const val DISCONNECT_BUTTON = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_DISCONNECT_BUTTON"
const val WALLET_ICON = "WALLET_CONNECT_DETAILS_BOTTOM_WALLET_ICON"
const val WALLET_TITLE = "WALLET_CONNECT_DETAILS_BOTTOM_WALLET"
const val WALLET_NAME = "WALLET_CONNECT_DETAILS_BOTTOM_WALLET_NAME"
const val NETWORKS_TITLE = "WALLET_CONNECT_DETAILS_BOTTOM_WALLET_NAME"
const val NETWORK_ITEM = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_NETWORK_ITEM"
const val NETWORK_ICON = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_NETWORK_ICON"
const val NETWORK_NAME = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_NETWORK_NAME"
const val NETWORK_SYMBOL = "WALLET_CONNECT_DETAILS_BOTTOM_SHEET_NETWORK_SYMBOL"
}

View file

@ -0,0 +1,10 @@
package com.tangem.core.ui.test
object WalletConnectScreenTestTags {
const val MORE_BUTTON = "WALLET_CONNECT_SCREEN_MORE_BUTTON"
const val WALLET_NAME = "WALLET_CONNECT_SCREEN_WALLET_NAME"
const val APP_ICON = "WALLET_CONNECT_SCREEN_APP_ICON"
const val APP_NAME = "WALLET_CONNECT_SCREEN_APP_NAME"
const val APPROVE_ICON = "WALLET_CONNECT_SCREEN_APPROVE_ICON"
const val APP_URL = "WALLET_CONNECT_SCREEN_APP_URL"
}

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="12dp"
android:height="12dp"
android:viewportWidth="12"
android:viewportHeight="12">
<path
android:pathData="M6,0L6,0A6,6 0,0 1,12 6L12,6A6,6 0,0 1,6 12L6,12A6,6 0,0 1,0 6L0,6A6,6 0,0 1,6 0z"
android:fillColor="#0099FF"/>
<group>
<clip-path
android:pathData="M2,2h8v8h-8z"/>
<path
android:pathData="M5.703,2.789C5.829,2.55 6.172,2.55 6.298,2.789L7.152,4.407C7.201,4.5 7.29,4.564 7.393,4.582L9.196,4.894C9.462,4.94 9.568,5.266 9.38,5.46L8.104,6.773C8.032,6.848 7.998,6.952 8.013,7.055L8.273,8.867C8.312,9.135 8.034,9.336 7.791,9.217L6.149,8.409C6.055,8.363 5.946,8.363 5.852,8.409L4.21,9.217C3.967,9.336 3.689,9.135 3.728,8.867L3.988,7.055C4.003,6.952 3.969,6.848 3.897,6.773L2.621,5.46C2.433,5.266 2.538,4.94 2.805,4.894L4.608,4.582C4.711,4.564 4.8,4.5 4.849,4.407L5.703,2.789Z"
android:fillColor="#ffffff"/>
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M8,11.848L11.332,13.901C11.857,14.225 12.506,13.744 12.366,13.134L11.482,9.264L14.426,6.66C14.89,6.25 14.642,5.472 14.031,5.419L10.155,5.084L8.639,1.432C8.4,0.856 7.6,0.856 7.361,1.432L5.845,5.084L1.969,5.419C1.358,5.472 1.11,6.25 1.574,6.66L4.518,9.264L3.634,13.134C3.494,13.744 4.142,14.225 4.668,13.901L8,11.848Z"
android:fillColor="#0099FF"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M10.495,1.6C10.535,1.462 10.357,1.368 10.265,1.478L3.851,9.114C3.778,9.2 3.84,9.333 3.953,9.333H6.953L5.505,14.398C5.466,14.537 5.643,14.631 5.735,14.521L12.149,6.885C12.222,6.798 12.161,6.666 12.047,6.666H9.048L10.495,1.6Z"
android:fillColor="#FFB71B"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="36"
android:viewportHeight="36">
<group>
<clip-path
android:pathData="M0,0h36v36h-36z"/>
<path
android:pathData="M18,18m-18,0a18,18 0,1 1,36 0a18,18 0,1 1,-36 0"
android:fillColor="#EBEBEB"/>
<path
android:pathData="M21.682,9.25H14.318C13.156,9.25 12.576,9.25 12.132,9.476C11.742,9.675 11.424,9.992 11.226,10.382C11,10.826 11,11.406 11,12.568V13.8H25V12.568C25,11.406 25,10.826 24.774,10.382C24.575,9.992 24.258,9.674 23.868,9.476C23.424,9.25 22.844,9.25 21.682,9.25Z"
android:fillColor="#1E1E1E"/>
<path
android:pathData="M20.333,17.65H25V23.432C25,24.594 25,25.174 24.773,25.618C24.574,26.008 24.258,26.326 23.868,26.524C23.423,26.75 22.843,26.75 21.681,26.75H20.334L20.333,17.65Z"
android:fillColor="#1E1E1E"/>
<path
android:pathData="M11,17.65H15.666V26.75H14.318C13.157,26.75 12.576,26.75 12.132,26.524C11.742,26.326 11.425,26.008 11.226,25.618C11,25.174 11,24.594 11,23.432V17.65Z"
android:fillColor="#1E1E1E"/>
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M11.999,1.846C13.546,1.846 14.796,2.398 15.854,2.976C16.977,3.588 17.75,4.132 18.658,4.438C19.267,4.642 20.029,4.812 20.464,5.322C20.812,5.73 20.891,6.27 20.979,6.78C22,12.753 19.662,19.35 13.72,21.75C13.155,21.978 12.718,22.154 12.002,22.154C11.286,22.154 10.849,21.978 10.284,21.75C4.342,19.35 2.002,12.748 3.022,6.78C3.11,6.27 3.189,5.73 3.537,5.322C3.972,4.812 4.734,4.642 5.343,4.438C6.25,4.132 7.023,3.588 8.145,2.976C9.202,2.398 10.452,1.846 11.999,1.846ZM16.903,8.645C16.577,8.346 16.072,8.368 15.773,8.694L10.963,13.949L9.146,11.966C8.848,11.64 8.343,11.618 8.017,11.916C7.691,12.214 7.669,12.72 7.967,13.046L10.373,15.675C10.524,15.84 10.739,15.934 10.963,15.935C11.187,15.935 11.402,15.84 11.554,15.675L16.953,9.774C17.251,9.449 17.229,8.943 16.903,8.645Z"
android:fillColor="#656565"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.513,2.602C13.689,2.603 13.859,2.604 14.024,2.607C14.049,2.608 14.074,2.609 14.098,2.609C14.688,2.624 15.209,2.654 15.665,2.716C16.585,2.84 17.344,3.102 17.943,3.701C18.543,4.301 18.805,5.059 18.929,5.979C19.049,6.87 19.049,8.006 19.049,9.43V14.074C19.005,14.067 18.964,14.06 18.926,14.055C18.764,14.033 18.598,14.022 18.455,14.018L18.121,14.015H12.122C11.101,12.924 9.649,12.225 8.019,12.225C7.018,12.225 6.087,12.484 5.281,12.936V9.43C5.281,8.006 5.282,6.87 5.401,5.979C5.525,5.059 5.787,4.301 6.387,3.701C6.986,3.102 7.745,2.84 8.665,2.716C9.121,2.655 9.641,2.624 10.231,2.609C10.255,2.609 10.28,2.608 10.306,2.607C10.47,2.604 10.641,2.603 10.816,2.602C11.22,2.597 11.652,2.596 12.115,2.596H12.215C12.677,2.596 13.109,2.597 13.513,2.602ZM11.276,5.039C10.786,5.039 10.389,5.436 10.389,5.927C10.389,6.417 10.786,6.815 11.276,6.815H13.053C13.543,6.815 13.941,6.417 13.941,5.927C13.941,5.436 13.543,5.039 13.053,5.039H11.276Z"
android:fillColor="#656565"/>
<path
android:pathData="M8.019,14.225C9.252,14.225 10.337,14.846 10.997,15.79C11.077,15.904 11.117,15.961 11.168,15.987C11.219,16.014 11.283,16.015 11.409,16.015H18.121C18.302,16.015 18.493,16.014 18.653,16.036C18.84,16.062 19.069,16.127 19.26,16.321C19.372,16.436 19.437,16.573 19.475,16.67C19.515,16.776 19.545,16.889 19.566,16.998C19.61,17.216 19.631,17.465 19.633,17.705C19.635,17.944 19.618,18.197 19.575,18.422C19.554,18.534 19.524,18.652 19.48,18.763C19.44,18.867 19.373,19.002 19.26,19.118C19.069,19.313 18.84,19.379 18.653,19.404C18.57,19.416 18.313,19.42 18.087,19.423C17.901,19.425 17.807,19.426 17.743,19.479C17.679,19.533 17.661,19.624 17.625,19.805C17.542,20.223 17.415,20.85 17.387,20.938C17.337,21.092 17.251,21.275 17.074,21.424C16.895,21.574 16.698,21.625 16.535,21.646L14.904,21.641C14.718,21.615 14.488,21.551 14.297,21.355C14.108,21.162 14.047,20.933 14.023,20.748C14.001,20.587 14.001,20.395 14.001,20.209V19.896C14.001,19.674 14.001,19.563 13.933,19.494C13.864,19.426 13.752,19.426 13.531,19.426H11.68C11.534,19.426 11.46,19.425 11.404,19.459C11.349,19.493 11.311,19.564 11.236,19.705C10.622,20.866 9.416,21.663 8.019,21.663C5.989,21.663 4.365,19.986 4.365,17.943C4.365,15.901 5.989,14.225 8.019,14.225ZM8.079,16.77C7.433,16.77 6.91,17.296 6.91,17.944C6.91,18.593 7.434,19.118 8.079,19.118H8.09C8.735,19.118 9.259,18.593 9.259,17.944C9.259,17.296 8.736,16.77 8.09,16.77H8.079Z"
android:fillColor="#656565"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,3C14.623,3 16.75,5.127 16.75,7.75V9.75H18C18.552,9.75 19,10.198 19,10.75V19.75C19,20.302 18.552,20.75 18,20.75H6C5.448,20.75 5,20.302 5,19.75V10.75C5,10.198 5.448,9.75 6,9.75H7.25V7.75C7.25,5.127 9.377,3 12,3ZM12,4.5C10.205,4.5 8.75,5.955 8.75,7.75V9.75H15.25V7.75C15.25,5.955 13.795,4.5 12,4.5Z"
android:fillColor="#656565"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="10dp"
android:height="10dp"
android:viewportWidth="10"
android:viewportHeight="10">
<path
android:pathData="M8.816,5.923C8.997,5.822 9.225,5.885 9.327,6.066C9.428,6.246 9.365,6.475 9.184,6.577L5.92,8.413C5.349,8.734 4.652,8.734 4.081,8.413L0.816,6.577C0.636,6.475 0.572,6.246 0.674,6.066C0.775,5.886 1.004,5.822 1.184,5.923L4.449,7.759C4.791,7.952 5.209,7.952 5.552,7.759L8.816,5.923ZM4.081,1.586C4.652,1.265 5.349,1.265 5.92,1.586L9.184,3.423C9.302,3.49 9.375,3.614 9.375,3.75C9.375,3.885 9.302,4.01 9.184,4.077L5.92,5.913C5.349,6.234 4.652,6.234 4.081,5.913L0.816,4.077C0.698,4.01 0.625,3.885 0.625,3.75C0.625,3.614 0.698,3.49 0.816,3.423L4.081,1.586ZM5.552,2.241C5.209,2.048 4.791,2.048 4.449,2.241L1.765,3.75L4.449,5.259C4.791,5.452 5.209,5.452 5.552,5.259L8.235,3.75L5.552,2.241Z"
android:fillColor="#919191"/>
</vector>

View file

@ -6,11 +6,11 @@
android:viewportHeight="24">
<path
android:pathData="M15.682,3.25H8.318C7.157,3.25 6.576,3.25 6.132,3.476C5.742,3.675 5.424,3.992 5.226,4.382C5,4.826 5,5.407 5,6.568V7.8H19V6.568C19,5.407 19,4.826 18.774,4.382C18.575,3.992 18.258,3.674 17.868,3.476C17.424,3.25 16.844,3.25 15.682,3.25Z"
android:fillColor="#1E1E1E"/>
android:fillColor="@color/icon_primary1"/>
<path
android:pathData="M14.333,11.65H19V17.432C19,18.594 19,19.174 18.773,19.618C18.574,20.008 18.258,20.326 17.868,20.524C17.423,20.75 16.843,20.75 15.681,20.75H14.334L14.333,11.65Z"
android:fillColor="#1E1E1E"/>
android:fillColor="@color/icon_primary1"/>
<path
android:pathData="M5,11.65H9.666V20.75H8.318C7.157,20.75 6.576,20.75 6.132,20.524C5.742,20.326 5.425,20.008 5.226,19.618C5,19.174 5,18.594 5,17.432V11.65Z"
android:fillColor="#1E1E1E"/>
android:fillColor="@color/icon_primary1"/>
</vector>

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M17.778,0L46.222,0A17.778,17.778 0,0 1,64 17.778L64,46.222A17.778,17.778 0,0 1,46.222 64L17.778,64A17.778,17.778 0,0 1,0 46.222L0,17.778A17.778,17.778 0,0 1,17.778 0z"
android:fillColor="#EBEBEB"/>
<path
android:pathData="M28.525,31.48V45.021H26.519C24.791,45.021 23.927,45.021 23.267,44.684C22.686,44.39 22.215,43.917 21.919,43.337C21.582,42.676 21.582,41.812 21.582,40.083V31.48H28.525Z"
android:fillColor="#1E1E1E"/>
<path
android:pathData="M42.414,40.083C42.414,41.812 42.414,42.676 42.077,43.337C41.781,43.917 41.31,44.39 40.73,44.684C40.069,45.021 39.205,45.021 37.476,45.021H35.471V32.441L35.47,31.48H42.414V40.083Z"
android:fillColor="#1E1E1E"/>
<path
android:pathData="M37.477,18.981C39.205,18.981 40.069,18.981 40.73,19.318C41.31,19.612 41.781,20.085 42.077,20.666C42.414,21.326 42.414,22.19 42.414,23.918V25.751H21.582V23.918C21.582,22.19 21.582,21.326 21.919,20.666C22.213,20.085 22.686,19.614 23.267,19.318C23.927,18.981 24.791,18.981 26.519,18.981H37.477Z"
android:fillColor="#1E1E1E"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="82dp"
android:height="82dp"
android:viewportWidth="450"
android:viewportHeight="450">
<path
android:fillColor="@color/icon_primary1"
android:pathData="M284.96,82H165.04C146.12,82 136.67,82 129.44,85.68C123.08,88.93 117.91,94.08 114.68,100.44C111,107.67 111,117.12 111,136.04V156.1H339V136.04C339,117.12 339,107.67 335.32,100.44C332.07,94.08 326.92,88.91 320.56,85.68C313.33,82 303.88,82 284.96,82Z" />
<path
android:fillColor="@color/icon_primary1"
android:pathData="M262.99,218.8H338.99V312.96C338.99,331.88 338.99,341.33 335.31,348.56C332.07,354.92 326.91,360.09 320.56,363.32C313.32,367 303.87,367 284.95,367H263.01L262.99,218.8Z" />
<path
android:fillColor="@color/icon_primary1"
android:pathData="M111,218.8H186.99V367H165.04C146.12,367 136.67,367 129.44,363.32C123.08,360.09 117.93,354.92 114.68,348.56C111,341.33 111,331.88 111,312.96V218.8Z" />
</vector>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_primary1">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_primary1">#FF1E1E1E</color>
</resources>

View file

@ -0,0 +1,6 @@
package com.tangem.utils.buildConfig
interface AppConfigurationProvider {
fun isDebug(): Boolean
fun isHuawei(): Boolean
}

View file

@ -1,6 +1,8 @@
package com.tangem.utils.coroutines
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
@ -54,4 +56,25 @@ fun CoroutineScope.launchOnCancellation(block: suspend () -> Unit) {
}
}
}
}
@Suppress("LongParameterList", "MagicNumber")
inline fun <T1, T2, T3, T4, T5, T6, R> combine6(
flow1: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = combine(flow1, flow2, flow3, flow4, flow5, flow6) { arr ->
@Suppress("UNCHECKED_CAST")
transform(
arr[0] as T1,
arr[1] as T2,
arr[2] as T3,
arr[3] as T4,
arr[4] as T5,
arr[5] as T6,
)
}