Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-18 13:32:52 +04:00
parent c037bec366
commit c061cc88f1
4 changed files with 404 additions and 100 deletions

View file

@ -1,6 +1,7 @@
package com.tangem.common
import androidx.test.core.app.ApplicationProvider
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.tap.ApplicationEntryPoint
import com.tangem.tap.TangemApplication
import dagger.hilt.android.testing.OnComponentReadyRunner
@ -10,7 +11,7 @@ import org.junit.runners.model.Statement
import timber.log.Timber
class ApplicationInjectionExecutionRule(
private val toggleStates: Map<String, Boolean>
private val toggleStates: Map<String, Boolean>,
) : TestRule {
private val tangemApplication: TangemApplication
@ -25,7 +26,7 @@ class ApplicationInjectionExecutionRule(
overrideFeatureToggles()
OnComponentReadyRunner.addListener(
tangemApplication, ApplicationEntryPoint::class.java
tangemApplication, ApplicationEntryPoint::class.java,
) { _: ApplicationEntryPoint ->
tangemApplication.preInit()
tangemApplication.init()
@ -43,25 +44,15 @@ class ApplicationInjectionExecutionRule(
@Suppress("UNCHECKED_CAST")
private fun saveOriginalFeatureToggles() {
try {
val featureTogglesClass = Class.forName("com.tangem.core.configtoggle.FeatureToggles")
val valuesField = featureTogglesClass.getDeclaredField("values")
valuesField.isAccessible = true
originalFeatureTogglesValues = valuesField.get(null) as Map<String, String>
originalFeatureTogglesValues = FeatureToggles.values as Map<String, String>
} catch (e: Exception) {
Timber.e("Failed to save original toggles values: ${e.message}")
}
}
@Suppress("UNCHECKED_CAST")
private fun overrideFeatureToggles() {
try {
val featureTogglesClass = Class.forName("com.tangem.core.configtoggle.FeatureToggles")
val valuesField = featureTogglesClass.getDeclaredField("values")
valuesField.isAccessible = true
val originalValues = originalFeatureTogglesValues ?:
(valuesField.get(null) as Map<String, String>)
val originalValues = originalFeatureTogglesValues ?: FeatureToggles.values
val newValues = originalValues.toMutableMap()
toggleStates.forEach { (toggle, enabled) ->
@ -72,10 +63,12 @@ class ApplicationInjectionExecutionRule(
}
}
valuesField.set(null, newValues)
val companionClass = FeatureToggles.Companion::class.java
val valuesField = companionClass.getDeclaredField("values")
valuesField.isAccessible = true
valuesField.set(FeatureToggles.Companion, newValues)
Timber.i("FeatureToggles.values updated: $toggleStates")
} catch (e: Exception) {
Timber.e("FeatureToggles.values didn't change with error: ${e.message}")
}
@ -84,10 +77,10 @@ class ApplicationInjectionExecutionRule(
private fun restoreOriginalFeatureToggles() {
try {
if (originalFeatureTogglesValues != null) {
val featureTogglesClass = Class.forName("com.tangem.core.configtoggle.FeatureToggles")
val valuesField = featureTogglesClass.getDeclaredField("values")
val companionClass = FeatureToggles.Companion::class.java
val valuesField = companionClass.getDeclaredField("values")
valuesField.isAccessible = true
valuesField.set(null, originalFeatureTogglesValues)
valuesField.set(FeatureToggles.Companion, originalFeatureTogglesValues)
Timber.i("FeatureToggles.values restored")
}
} catch (e: Exception) {

View file

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

View file

@ -0,0 +1,100 @@
package com.tangem.plugin.configuration.configurations
import com.squareup.kotlinpoet.*
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import java.util.Locale
/**
* Generator for toggle configuration Kotlin enum classes from JSON files.
* Parses a JSON array of `{ "name": "...", "version": "..." }` entries
* and generates an enum class with an entry for each toggle and a `values: Map<String, String>`
* in companion object mapping toggle name to its version string.
*
[REDACTED_AUTHOR]
*/
object TogglesGenerator {
private const val PACKAGE_NAME = "com.tangem.core.configtoggle"
/**
* Generates a Kotlin enum class from a JSON toggles config file.
*
* @param inputFile JSON configuration file (array of objects with "name" and "version" fields)
* @param outputDir output directory for the generated Kotlin file
* @param fileName name of the generated Kotlin enum class (e.g. "FeatureToggles")
*/
fun generate(inputFile: File, outputDir: File, fileName: String) {
val jsonText = inputFile.readText()
val jsonArray = Json.parseToJsonElement(jsonText).jsonArray
val entries = jsonArray.map { element ->
val obj = element.jsonObject
val name = obj.getValue("name").jsonPrimitive.content
val version = obj.getValue("version").jsonPrimitive.content
ToggleEntry(name = name, enumName = name.toEnumEntryName(), version = version)
}
val mapInitializer = buildCodeBlock {
addStatement("mapOf(")
withIndent {
entries.forEach { entry ->
addStatement("%S to %S,", entry.name, entry.version)
}
}
add(")")
}
val companionBuilder = TypeSpec.companionObjectBuilder()
.addProperty(
PropertySpec.builder("values", MAP.parameterizedBy(STRING, STRING))
.initializer(mapInitializer)
.build(),
)
val enumBuilder = TypeSpec.enumBuilder(fileName)
.addKdoc("Generated from ${inputFile.name}\nAuto-generated - do not edit manually.")
.apply {
entries.forEach { entry ->
addEnumConstant(entry.enumName)
}
}
.addType(companionBuilder.build())
val fileSpec = FileSpec.builder(packageName = PACKAGE_NAME, fileName = fileName)
.indent(" ")
.addType(enumBuilder.build())
.build()
outputDir.mkdirs()
fileSpec.writeTo(outputDir)
// Remove redundant public visibility modifiers
val generatedFile = File(outputDir, "${PACKAGE_NAME.replace('.', '/')}/$fileName.kt")
if (generatedFile.exists()) {
val content = generatedFile.readText()
val fixedContent = content
.replace("public enum class ", "enum class ")
.replace("public companion object", "companion object")
.replace("public val ", "val ")
generatedFile.writeText(fixedContent)
}
}
/**
* Converts a toggle name to a valid Kotlin enum entry name.
* - Replaces `/`, `-`, `.`, spaces with `_`
* - Converts to UPPER_CASE
* - Empty string becomes `EMPTY`
*/
private fun String.toEnumEntryName(): String {
if (isBlank()) return "EMPTY"
return replace(Regex("[/\\-. ]"), "_").uppercase(Locale.ROOT)
}
private data class ToggleEntry(val name: String, val enumName: String, val version: String)
}

View file

@ -0,0 +1,253 @@
package com.tangem.plugin.configuration.configurations
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
/**
* Tests for [TogglesGenerator].
*/
class TogglesGeneratorTest {
@TempDir
lateinit var tempDir: File
private lateinit var outputDir: File
@BeforeEach
fun setup() {
outputDir = File(tempDir, "output")
}
@Test
fun `generate creates enum class with correct package`() {
// Arrange & Act
val code = generateAndReadOutput(
json = SINGLE_ENTRY_JSON,
fileName = "FeatureToggles",
)
// Assert
assertThat(code).contains("package com.tangem.core.configtoggle")
}
@Test
fun `generate creates enum class with given name`() {
// Arrange & Act
val code = generateAndReadOutput(
json = SINGLE_ENTRY_JSON,
fileName = "FeatureToggles",
)
// Assert
assertThat(code).contains("enum class FeatureToggles {")
}
@Test
fun `generate creates enum entries and companion object`() {
// Arrange & Act
val code = generateAndReadOutput(
json = SINGLE_ENTRY_JSON,
fileName = "FeatureToggles",
)
// Assert
assertThat(code).contains("STAKING_ETH_ENABLED,")
assertThat(code).contains("companion object {")
}
@Test
fun `generate creates values map with string literal keys`() {
// Arrange & Act
val code = generateAndReadOutput(
json = SINGLE_ENTRY_JSON,
fileName = "FeatureToggles",
)
// Assert
assertThat(code).contains("val values: Map<String, String> = mapOf(")
assertThat(code).contains(""""STAKING_ETH_ENABLED" to "undefined"""")
}
@Test
fun `generate creates enum with multiple entries`() {
// Arrange
val json = """
[
{ "name": "FEATURE_A", "version": "1.0" },
{ "name": "FEATURE_B", "version": "2.0" },
{ "name": "FEATURE_C", "version": "undefined" }
]
""".trimIndent()
// Act
val code = generateAndReadOutput(json, "TestToggles")
// Assert
assertThat(code).contains("FEATURE_A,")
assertThat(code).contains("FEATURE_B,")
assertThat(code).contains("FEATURE_C,")
assertThat(code).contains(""""FEATURE_A" to "1.0"""")
assertThat(code).contains(""""FEATURE_B" to "2.0"""")
assertThat(code).contains(""""FEATURE_C" to "undefined"""")
}
@Test
fun `generate handles empty array`() {
// Arrange & Act
val code = generateAndReadOutput(
json = "[]",
fileName = "EmptyToggles",
)
// Assert
assertThat(code).contains("enum class EmptyToggles {")
assertThat(code).contains("val values: Map<String, String> = mapOf(")
}
@Test
fun `generate converts slash in name to underscore in enum`() {
// Arrange
val json = """[{ "name": "NEXA/test", "version": "undefined" }]"""
// Act
val code = generateAndReadOutput(json, "Toggles")
// Assert
assertThat(code).contains("NEXA_TEST,")
assertThat(code).contains(""""NEXA/test" to "undefined"""")
}
@Test
fun `generate converts dash in name to underscore in enum`() {
// Arrange
val json = """[{ "name": "vanar-chain", "version": "undefined" }]"""
// Act
val code = generateAndReadOutput(json, "Toggles")
// Assert
assertThat(code).contains("VANAR_CHAIN,")
assertThat(code).contains(""""vanar-chain" to "undefined"""")
}
@Test
fun `generate uppercases lowercase names in enum`() {
// Arrange
val json = """[{ "name": "sonic", "version": "5.21.0" }]"""
// Act
val code = generateAndReadOutput(json, "Toggles")
// Assert
assertThat(code).contains("SONIC,")
assertThat(code).contains(""""sonic" to "5.21.0"""")
}
@Test
fun `generate adds kdoc with source file reference`() {
// Arrange & Act
val code = generateAndReadOutput(
json = SINGLE_ENTRY_JSON,
fileName = "FeatureToggles",
)
// Assert
assertThat(code).contains("Generated from")
assertThat(code).contains("Auto-generated - do not edit manually")
}
@Test
fun `generate removes public modifiers`() {
// Arrange & Act
val code = generateAndReadOutput(
json = SINGLE_ENTRY_JSON,
fileName = "FeatureToggles",
)
// Assert
assertThat(code).doesNotContain("public enum class")
assertThat(code).doesNotContain("public companion object")
assertThat(code).doesNotContain("public val")
}
@Test
fun `generate handles real feature toggles config`() {
// Arrange
val json = """
[
{ "name": "NEW_CARD_SCANNING_ENABLED", "version": "undefined" },
{ "name": "HOT_WALLET_CREATION_RESTRICTION_ENABLED", "version": "5.32.0" },
{ "name": "SWAP_MARKET_LIST_ENABLED", "version": "5.34" }
]
""".trimIndent()
// Act
val code = generateAndReadOutput(json, "FeatureToggles")
// Assert
assertThat(code).contains("NEW_CARD_SCANNING_ENABLED,")
assertThat(code).contains("HOT_WALLET_CREATION_RESTRICTION_ENABLED,")
assertThat(code).contains("SWAP_MARKET_LIST_ENABLED,")
assertThat(code).contains(""""NEW_CARD_SCANNING_ENABLED" to "undefined"""")
assertThat(code).contains(""""HOT_WALLET_CREATION_RESTRICTION_ENABLED" to "5.32.0"""")
assertThat(code).contains(""""SWAP_MARKET_LIST_ENABLED" to "5.34"""")
}
@Test
fun `generate handles real excluded blockchains config`() {
// Arrange
val json = """
[
{ "name": "NEXA", "version": "undefined" },
{ "name": "NEXA/test", "version": "undefined" },
{ "name": "sonic", "version": "5.21.0" }
]
""".trimIndent()
// Act
val code = generateAndReadOutput(json, "ExcludedBlockchainToggles")
// Assert
assertThat(code).contains("enum class ExcludedBlockchainToggles {")
assertThat(code).contains("NEXA,")
assertThat(code).contains("NEXA_TEST,")
assertThat(code).contains("SONIC,")
assertThat(code).contains(""""NEXA" to "undefined"""")
assertThat(code).contains(""""NEXA/test" to "undefined"""")
assertThat(code).contains(""""sonic" to "5.21.0"""")
}
@Test
fun `generate produces different objects for different file names`() {
// Arrange
val json = SINGLE_ENTRY_JSON
// Act
val code1 = generateAndReadOutput(json, "FeatureToggles")
val code2 = generateAndReadOutput(json, "ExcludedBlockchainToggles")
// Assert
assertThat(code1).contains("enum class FeatureToggles {")
assertThat(code2).contains("enum class ExcludedBlockchainToggles {")
}
private fun generateAndReadOutput(json: String, fileName: String): String {
val inputFile = File(tempDir, "config.json").apply {
writeText(json)
}
TogglesGenerator.generate(inputFile, outputDir, fileName)
val generatedFile = File(outputDir, "com/tangem/core/configtoggle/$fileName.kt")
assertThat(generatedFile.exists()).isTrue()
return generatedFile.readText()
}
private companion object {
const val SINGLE_ENTRY_JSON = """[{ "name": "STAKING_ETH_ENABLED", "version": "undefined" }]"""
}
}