diff --git a/app/src/androidTest/kotlin/com/tangem/common/ApplicationInjectionExecutionRule.kt b/app/src/androidTest/kotlin/com/tangem/common/ApplicationInjectionExecutionRule.kt index 2445305b72..e41aee1ef2 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/ApplicationInjectionExecutionRule.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/ApplicationInjectionExecutionRule.kt @@ -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 + private val toggleStates: Map, ) : 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 + originalFeatureTogglesValues = FeatureToggles.values as Map } 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) - + 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) { diff --git a/core/config-toggles/build.gradle.kts b/core/config-toggles/build.gradle.kts index 490d2c6fa8..4b01f4402d 100644 --- a/core/config-toggles/build.gradle.kts +++ b/core/config-toggles/build.gradle.kts @@ -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 + + @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("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().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) - } - } } \ No newline at end of file diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/TogglesGenerator.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/TogglesGenerator.kt new file mode 100644 index 0000000000..be4edcd2f0 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/TogglesGenerator.kt @@ -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` + * 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) +} \ No newline at end of file diff --git a/plugins/configuration/src/test/kotlin/com/tangem/plugin/configuration/configurations/TogglesGeneratorTest.kt b/plugins/configuration/src/test/kotlin/com/tangem/plugin/configuration/configurations/TogglesGeneratorTest.kt new file mode 100644 index 0000000000..27d25144db --- /dev/null +++ b/plugins/configuration/src/test/kotlin/com/tangem/plugin/configuration/configurations/TogglesGeneratorTest.kt @@ -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 = 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 = 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" }]""" + } +} \ No newline at end of file