diff --git a/crowdin-compiler-plugin/.gitignore b/crowdin-compiler-plugin/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/crowdin-compiler-plugin/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/crowdin-compiler-plugin/build.gradle.kts b/crowdin-compiler-plugin/build.gradle.kts new file mode 100644 index 0000000..df491e7 --- /dev/null +++ b/crowdin-compiler-plugin/build.gradle.kts @@ -0,0 +1,154 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + id("java") + id("maven-publish") + alias(libs.plugins.buildconfig) + alias(libs.plugins.gradle.java.test.fixtures) + alias(libs.plugins.gradle.idea) +} + +apply(from = rootProject.file("gradle/publishing.gradle.kts")) + +sourceSets { + main { + java.setSrcDirs(listOf("src")) + resources.setSrcDirs(listOf("resources")) + } + testFixtures { + java.setSrcDirs(listOf("test-fixtures")) + } + test { + java.setSrcDirs(listOf("test", "test-gen")) + resources.setSrcDirs(listOf("testData")) + } +} + +idea { + module.generatedSourceDirs.add(projectDir.resolve("test-gen")) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + jvmToolchain(17) +} + +val annotationsRuntimeClasspath: Configuration by configurations.creating { isTransitive = false } + +dependencies { + compileOnly(libs.kotlin.compiler) + + + testFixturesApi(libs.kotlin.test.junit5) + testFixturesApi(libs.kotlin.test.framework) + testFixturesApi(libs.kotlin.compiler) + + // Dependencies required to run the internal test framework. + testRuntimeOnly(libs.junit) + testRuntimeOnly(libs.kotlin.reflect) + testRuntimeOnly(libs.kotlin.test) + testRuntimeOnly(libs.kotlin.script.runtime) + testRuntimeOnly(libs.kotlin.annotations.jvm) +} + +buildConfig { + useKotlinOutput { + internalVisibility = true + } + + packageName(group.toString()) + buildConfigField("String", "KOTLIN_PLUGIN_ID", "\"${rootProject.group}\"") +} + +tasks.test { + dependsOn(annotationsRuntimeClasspath) + + useJUnitPlatform() + workingDir = rootDir + + systemProperty("annotationsRuntime.classpath", annotationsRuntimeClasspath.asPath) + + // Properties required to run the internal test framework. + setLibraryProperty("org.jetbrains.kotlin.test.kotlin-stdlib", "kotlin-stdlib") + setLibraryProperty("org.jetbrains.kotlin.test.kotlin-stdlib-jdk8", "kotlin-stdlib-jdk8") + setLibraryProperty("org.jetbrains.kotlin.test.kotlin-reflect", "kotlin-reflect") + setLibraryProperty("org.jetbrains.kotlin.test.kotlin-test", "kotlin-test") + setLibraryProperty("org.jetbrains.kotlin.test.kotlin-script-runtime", "kotlin-script-runtime") + setLibraryProperty("org.jetbrains.kotlin.test.kotlin-annotations-jvm", "kotlin-annotations-jvm") + + systemProperty("idea.ignore.disabled.plugins", "true") + systemProperty("idea.home.path", rootDir) +} + +kotlin { + jvmToolchain(17) + + compilerOptions { + optIn.add("org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi") + optIn.add("org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI") + } +} + +val generateTests by tasks.registering(JavaExec::class) { + inputs.dir(layout.projectDirectory.dir("testData")) + .withPropertyName("testData") + .withPathSensitivity(PathSensitivity.RELATIVE) + outputs.dir(layout.projectDirectory.dir("test-gen")) + .withPropertyName("generatedTests") + + classpath = sourceSets.testFixtures.get().runtimeClasspath + mainClass.set("com.crowdin.platform.compiler.GenerateTestsKt") + workingDir = rootDir +} + +tasks.compileTestKotlin { + dependsOn(generateTests) +} + +fun Test.setLibraryProperty(propName: String, jarName: String) { + val path = project.configurations + .testRuntimeClasspath.get() + .files + .find { """$jarName-\d.*jar""".toRegex().matches(it.name) } + ?.absolutePath + ?: return + systemProperty(propName, path) +} + +// Create sources JAR +val sourcesJar by tasks.registering(Jar::class) { + archiveClassifier.set("sources") + from(sourceSets.main.get().allSource) +} + +publishing { + publications { + create("maven") { + from(components["java"]) + artifact(sourcesJar) + + groupId = project.property("publishedGroupId") as String + artifactId = "compiler-plugin" + version = project.property("crowdinVersion") as String + + val crowdinPublishing = project.extra["CrowdinPublishing"] as Any + val configureMethod = crowdinPublishing.javaClass.getMethod( + "configurePom", + org.gradle.api.publish.maven.MavenPublication::class.java, + Project::class.java, + String::class.java, + String::class.java + ) + configureMethod.invoke( + crowdinPublishing, + this, + project, + "Crowdin Kotlin Compiler Plugin", + "Kotlin compiler plugin that transforms stringResource() calls to use Crowdin SDK for real-time translation updates in Jetpack Compose" + ) + } + } +} \ No newline at end of file diff --git a/crowdin-compiler-plugin/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor b/crowdin-compiler-plugin/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor new file mode 100644 index 0000000..8a3ea5c --- /dev/null +++ b/crowdin-compiler-plugin/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor @@ -0,0 +1 @@ +com.crowdin.platform.compiler.CrowdinCommandLineProcessor diff --git a/crowdin-compiler-plugin/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar b/crowdin-compiler-plugin/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar new file mode 100644 index 0000000..db0fa14 --- /dev/null +++ b/crowdin-compiler-plugin/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar @@ -0,0 +1 @@ +com.crowdin.platform.compiler.CrowdinComponentRegistrar diff --git a/crowdin-compiler-plugin/src/main/kotlin/com/crowdin/platform/compiler/CrowdinCommandLineProcessor.kt b/crowdin-compiler-plugin/src/main/kotlin/com/crowdin/platform/compiler/CrowdinCommandLineProcessor.kt new file mode 100644 index 0000000..2d2a2f5 --- /dev/null +++ b/crowdin-compiler-plugin/src/main/kotlin/com/crowdin/platform/compiler/CrowdinCommandLineProcessor.kt @@ -0,0 +1,35 @@ +package com.crowdin.platform.compiler + +import org.jetbrains.kotlin.compiler.plugin.AbstractCliOption +import org.jetbrains.kotlin.compiler.plugin.CliOption +import org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor +import org.jetbrains.kotlin.config.CompilerConfiguration + +/** + * Command line processor for the Crowdin compiler plugin. + * + * This processes the command-line options passed to the compiler plugin + * and stores them in the compiler configuration. + */ +class CrowdinCommandLineProcessor : CommandLineProcessor { + override val pluginId: String = CrowdinComponentRegistrar.PLUGIN_ID + + override val pluginOptions: Collection = listOf( + CliOption( + optionName = "enabled", + valueDescription = "true|false", + description = "Enable or disable the Crowdin compiler plugin", + required = false + ) + ) + + override fun processOption( + option: AbstractCliOption, + value: String, + configuration: CompilerConfiguration + ) { + when (option.optionName) { + "enabled" -> configuration.put(CrowdinComponentRegistrar.KEY_ENABLED, value) + } + } +} diff --git a/crowdin-compiler-plugin/src/main/kotlin/com/crowdin/platform/compiler/CrowdinComponentRegistrar.kt b/crowdin-compiler-plugin/src/main/kotlin/com/crowdin/platform/compiler/CrowdinComponentRegistrar.kt new file mode 100644 index 0000000..f77dc2d --- /dev/null +++ b/crowdin-compiler-plugin/src/main/kotlin/com/crowdin/platform/compiler/CrowdinComponentRegistrar.kt @@ -0,0 +1,34 @@ +package com.crowdin.platform.compiler + +import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension +import org.jetbrains.kotlin.cli.common.messages.MessageCollector +import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +import org.jetbrains.kotlin.config.CommonConfigurationKeys +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.config.CompilerConfigurationKey + +/** + * Component registrar for the Crowdin compiler plugin. + * + * This is the entry point for the Kotlin compiler to discover and initialize + * our plugin. It registers the IR generation extension that performs the + * stringResource -> crowdinString transformation. + */ +class CrowdinComponentRegistrar : CompilerPluginRegistrar() { + + companion object { + val KEY_ENABLED = CompilerConfigurationKey("crowdin.enabled") + const val PLUGIN_ID = "com.crowdin.platform.compiler" + } + + override val supportsK2: Boolean = true // Now using K2-compatible APIs + + override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { + val messageCollector = configuration.get(CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE) + // Check if the plugin is enabled (defaults to false for release builds) + val enabled = configuration.get(KEY_ENABLED, "false").toBoolean() + + // Register our IR generation extension + IrGenerationExtension.registerExtension(CrowdinIrGenerationExtension(messageCollector, enabled)) + } +} diff --git a/crowdin-compiler-plugin/src/main/kotlin/com/crowdin/platform/compiler/CrowdinIrGenerationExtension.kt b/crowdin-compiler-plugin/src/main/kotlin/com/crowdin/platform/compiler/CrowdinIrGenerationExtension.kt new file mode 100644 index 0000000..cc3633e --- /dev/null +++ b/crowdin-compiler-plugin/src/main/kotlin/com/crowdin/platform/compiler/CrowdinIrGenerationExtension.kt @@ -0,0 +1,69 @@ +package com.crowdin.platform.compiler + +import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity +import org.jetbrains.kotlin.cli.common.messages.MessageCollector +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrModuleFragment +import org.jetbrains.kotlin.ir.types.classFqName +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid + +/** + * IR generation extension that applies the Crowdin string resource transformer. + * + * This extension is invoked during the IR generation phase and applies our + * transformer to all IR elements in the module. + */ +class CrowdinIrGenerationExtension( + private val messageCollector: MessageCollector, + private val enabled: Boolean +) : IrGenerationExtension { + + override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { + if (!enabled) { + messageCollector.report(CompilerMessageSeverity.LOGGING, "[Crowdin IrGenerationExtension] Plugin disabled, skipping transformation") + return + } + + // Check if Compose has already run + if (hasComposeTransformations(moduleFragment)) { + error( + """ + ERROR: Compose has already transformed the IR! + CrowdinGradlePlugin must run BEFORE Compose. + + Make sure CrowdinGradlePlugin is applied before Compose: + plugins { + id("com.crowdin.platform.gradle") + id("org.jetbrains.kotlin.plugin.compose") + } + """.trimIndent() + ) + } + + messageCollector.report(CompilerMessageSeverity.LOGGING, "[Crowdin IrGenerationExtension] Applying transformer to module: ${moduleFragment.name}") + + // Apply the transformer to all IR elements in the module + val transformer = CrowdinStringResourceTransformer(pluginContext, enabled) + moduleFragment.transformChildrenVoid(transformer) + } + + private fun hasComposeTransformations(moduleFragment: IrModuleFragment): Boolean { + // Check for Compose-specific IR patterns + return moduleFragment.files.any { file -> + file.declarations.any { declaration -> + when (declaration) { + is IrFunction -> { + // Compose adds a Composer parameter + declaration.parameters.any { param -> + param.type.classFqName?.asString() == + "androidx.compose.runtime.Composer" + } + } + else -> false + } + } + } + } +} diff --git a/crowdin-compiler-plugin/src/main/kotlin/com/crowdin/platform/compiler/CrowdinStringResourceTransformer.kt b/crowdin-compiler-plugin/src/main/kotlin/com/crowdin/platform/compiler/CrowdinStringResourceTransformer.kt new file mode 100644 index 0000000..a5af5e0 --- /dev/null +++ b/crowdin-compiler-plugin/src/main/kotlin/com/crowdin/platform/compiler/CrowdinStringResourceTransformer.kt @@ -0,0 +1,116 @@ +package com.crowdin.platform.compiler + +import org.jetbrains.kotlin.backend.common.IrElementTransformerVoidWithContext +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.classFqName +import org.jetbrains.kotlin.ir.util.fqNameWhenAvailable +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name + +/** + * IR transformer that intercepts stringResource() calls and replaces them with crowdinString(). + * + * This transformer runs during the IR lowering phase and rewrites function calls at the + * intermediate representation level, making it completely transparent to the user. + * + * Uses K2-compatible CallableId API for function resolution. + */ +class CrowdinStringResourceTransformer( + private val pluginContext: IrPluginContext, + private val enabled: Boolean +) : IrElementTransformerVoidWithContext() { + companion object { + // The FQN of the original stringResource function from Compose + private val STRING_RESOURCE_FQN = FqName("androidx.compose.ui.res.stringResource") + + // The FQN of our crowdinString replacement function + private val CROWDIN_STRING_NAME = Name.identifier("crowdinString") + + // Package names for CallableId + private val CROWDIN_PACKAGE = FqName("com.crowdin.platform.compose") + } + + override fun visitCall(expression: IrCall): IrExpression { + + // If the plugin is disabled, don't transform anything + if (!enabled) { + return super.visitCall(expression) + } + + val callee = expression.symbol.owner + val calleeFqName = callee.fqNameWhenAvailable + + // Check if this is a call to stringResource() + if (calleeFqName == STRING_RESOURCE_FQN) { + + val callableId = CallableId(CROWDIN_PACKAGE, CROWDIN_STRING_NAME) + val crowdinStringSymbols = pluginContext.referenceFunctions(callableId) + + val crowdinStringSymbol = findMatchingOverload( + callee, + crowdinStringSymbols.map { it.owner } + )?.symbol + + if (crowdinStringSymbol != null) { + expression.symbol = crowdinStringSymbol + + return expression + } else { + val searchedFqn = callableId.toString() + val originalSignature = buildString { + append(callee.name) + append("(") + append(callee.parameters.joinToString(", ") { it.type.classFqName?.asString() ?: it.type.toString() }) + append("): ") + append(callee.returnType.classFqName?.asString() ?: callee.returnType.toString()) + } + val candidateCount = crowdinStringSymbols.size + val packageName = CROWDIN_PACKAGE.asString() + error( + "Could not find crowdinString function.\n" + + "Searched for: $searchedFqn\n" + + "Original signature: $originalSignature\n" + + "Package: $packageName\n" + + "Number of candidates found: $candidateCount" + ) + } + } + + return super.visitCall(expression) + } + + private fun findMatchingOverload( + original: IrSimpleFunction, + candidates: Collection + ): IrSimpleFunction? { + return candidates.firstOrNull { candidate -> + // Must have same parameter count + if (candidate.parameters.size != original.parameters.size) { + return@firstOrNull false + } + + // Must have same return type + if (!areTypesCompatible(candidate.returnType, original.returnType)) { + return@firstOrNull false + } + + // All parameters must match by type + candidate.parameters.zip(original.parameters).all { (candidateParam, originalParam) -> + areTypesCompatible(candidateParam.type, originalParam.type) + } + } + } + + private fun areTypesCompatible(candidateType: IrType, originalType: IrType): Boolean { + // Exact match + if (candidateType == originalType) return true + + // Compare class FQN - no type system needed! + return candidateType.classFqName == originalType.classFqName + } +} diff --git a/crowdin-compiler-plugin/test-fixtures/com/crowdin/platform/compiler/GenerateTests.kt b/crowdin-compiler-plugin/test-fixtures/com/crowdin/platform/compiler/GenerateTests.kt new file mode 100644 index 0000000..6b5c931 --- /dev/null +++ b/crowdin-compiler-plugin/test-fixtures/com/crowdin/platform/compiler/GenerateTests.kt @@ -0,0 +1,14 @@ +package com.crowdin.platform.compiler + +import com.crowdin.platform.compiler.runners.AbstractJvmBoxTest +import org.jetbrains.kotlin.generators.generateTestGroupSuiteWithJUnit5 + +fun main() { + generateTestGroupSuiteWithJUnit5 { + testGroup(testDataRoot = "crowdin-compiler-plugin/testData", testsRoot = "crowdin-compiler-plugin/test-gen") { + testClass { + model("box") + } + } + } +} \ No newline at end of file diff --git a/crowdin-compiler-plugin/test-fixtures/com/crowdin/platform/compiler/runners/AbstractJvmBoxTest.kt b/crowdin-compiler-plugin/test-fixtures/com/crowdin/platform/compiler/runners/AbstractJvmBoxTest.kt new file mode 100644 index 0000000..892d2a0 --- /dev/null +++ b/crowdin-compiler-plugin/test-fixtures/com/crowdin/platform/compiler/runners/AbstractJvmBoxTest.kt @@ -0,0 +1,41 @@ +package com.crowdin.platform.compiler.runners + +import com.crowdin.platform.compiler.services.configurePlugin +import org.jetbrains.kotlin.test.FirParser +import org.jetbrains.kotlin.test.builders.TestConfigurationBuilder +import org.jetbrains.kotlin.test.directives.CodegenTestDirectives +import org.jetbrains.kotlin.test.directives.FirDiagnosticsDirectives +import org.jetbrains.kotlin.test.directives.JvmEnvironmentConfigurationDirectives +import org.jetbrains.kotlin.test.runners.codegen.AbstractFirBlackBoxCodegenTestBase +import org.jetbrains.kotlin.test.services.EnvironmentBasedStandardLibrariesPathProvider +import org.jetbrains.kotlin.test.services.KotlinStandardLibrariesPathProvider + +open class AbstractJvmBoxTest : AbstractFirBlackBoxCodegenTestBase(FirParser.LightTree) { + override fun createKotlinStandardLibrariesPathProvider(): KotlinStandardLibrariesPathProvider { + return EnvironmentBasedStandardLibrariesPathProvider + } + + override fun configure(builder: TestConfigurationBuilder) = with(builder) { + super.configure(this) + /* + * Containers of different directives, which can be used in tests: + * - ModuleStructureDirectives + * - LanguageSettingsDirectives + * - DiagnosticsDirectives + * - FirDiagnosticsDirectives + * - CodegenTestDirectives + * - JvmEnvironmentConfigurationDirectives + * + * All of them are located in `org.jetbrains.kotlin.test.directives` package + */ + defaultDirectives { + +CodegenTestDirectives.DUMP_IR + +FirDiagnosticsDirectives.FIR_DUMP + +JvmEnvironmentConfigurationDirectives.FULL_JDK + + +CodegenTestDirectives.IGNORE_DEXING // Avoids loading R8 from the classpath. + } + + configurePlugin() + } +} \ No newline at end of file diff --git a/crowdin-compiler-plugin/test-fixtures/com/crowdin/platform/compiler/services/ExtensionRegistrarConfigurator.kt b/crowdin-compiler-plugin/test-fixtures/com/crowdin/platform/compiler/services/ExtensionRegistrarConfigurator.kt new file mode 100644 index 0000000..7cb84ad --- /dev/null +++ b/crowdin-compiler-plugin/test-fixtures/com/crowdin/platform/compiler/services/ExtensionRegistrarConfigurator.kt @@ -0,0 +1,27 @@ +package com.crowdin.platform.compiler.services + +import com.crowdin.platform.compiler.CrowdinIrGenerationExtension +import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension +import org.jetbrains.kotlin.cli.common.messages.MessageCollector +import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +import org.jetbrains.kotlin.config.CommonConfigurationKeys +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.test.builders.TestConfigurationBuilder +import org.jetbrains.kotlin.test.model.TestModule +import org.jetbrains.kotlin.test.services.EnvironmentConfigurator +import org.jetbrains.kotlin.test.services.TestServices + +fun TestConfigurationBuilder.configurePlugin() { + useConfigurators(::ExtensionRegistrarConfigurator) +} + +private class ExtensionRegistrarConfigurator(testServices: TestServices) : EnvironmentConfigurator(testServices) { + override fun CompilerPluginRegistrar.ExtensionStorage.registerCompilerExtensions( + module: TestModule, + configuration: CompilerConfiguration + ) { + val messageCollector = configuration.get(CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE) + + IrGenerationExtension.registerExtension(CrowdinIrGenerationExtension(messageCollector, true)) + } +} \ No newline at end of file diff --git a/crowdin-compiler-plugin/test-gen/com/crowdin/platform/compiler/runners/JvmBoxTestGenerated.java b/crowdin-compiler-plugin/test-gen/com/crowdin/platform/compiler/runners/JvmBoxTestGenerated.java new file mode 100644 index 0000000..7bd007a --- /dev/null +++ b/crowdin-compiler-plugin/test-gen/com/crowdin/platform/compiler/runners/JvmBoxTestGenerated.java @@ -0,0 +1,29 @@ + + +package com.crowdin.platform.compiler.runners; + +import com.intellij.testFramework.TestDataPath; +import org.jetbrains.kotlin.test.util.KtTestUtil; +import org.jetbrains.kotlin.test.TargetBackend; +import org.jetbrains.kotlin.test.TestMetadata; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.regex.Pattern; + +/** This class is generated by {@link com.crowdin.platform.compiler.GenerateTestsKt}. DO NOT MODIFY MANUALLY */ +@SuppressWarnings("all") +@TestMetadata("crowdin-compiler-plugin/testData/box") +@TestDataPath("$PROJECT_ROOT") +public class JvmBoxTestGenerated extends AbstractJvmBoxTest { + @Test + public void testAllFilesPresentInBox() { + KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("crowdin-compiler-plugin/testData/box"), Pattern.compile("^(.+)\\.kt$"), null, TargetBackend.JVM_IR, true); + } + + @Test + @TestMetadata("stringResourceTransform.kt") + public void testStringResourceTransform() { + runTest("crowdin-compiler-plugin/testData/box/stringResourceTransform.kt"); + } +} diff --git a/crowdin-compiler-plugin/testData/box/stringResourceTransform.fir.ir.txt b/crowdin-compiler-plugin/testData/box/stringResourceTransform.fir.ir.txt new file mode 100644 index 0000000..d59d363 --- /dev/null +++ b/crowdin-compiler-plugin/testData/box/stringResourceTransform.fir.ir.txt @@ -0,0 +1,85 @@ +FILE fqName:com.crowdin.platform.compose fileName:/crowdinHelpers.kt + CLASS OBJECT name:R modality:FINAL visibility:public superTypes:[kotlin.Any] + thisReceiver: VALUE_PARAMETER INSTANCE_RECEIVER kind:DispatchReceiver name: type:com.crowdin.platform.compose.R + CLASS OBJECT name:string modality:FINAL visibility:public superTypes:[kotlin.Any] + thisReceiver: VALUE_PARAMETER INSTANCE_RECEIVER kind:DispatchReceiver name: type:com.crowdin.platform.compose.R.string + CONSTRUCTOR visibility:private returnType:com.crowdin.platform.compose.R.string [primary] + BLOCK_BODY + DELEGATING_CONSTRUCTOR_CALL 'public constructor () declared in kotlin.Any' + INSTANCE_INITIALIZER_CALL classDescriptor='CLASS OBJECT name:string modality:FINAL visibility:public superTypes:[kotlin.Any]' type=kotlin.Unit + FUN FAKE_OVERRIDE name:equals visibility:public modality:OPEN returnType:kotlin.Boolean [fake_override,operator] + VALUE_PARAMETER kind:DispatchReceiver name: index:0 type:kotlin.Any + VALUE_PARAMETER kind:Regular name:other index:1 type:kotlin.Any? + overridden: + public open fun equals (other: kotlin.Any?): kotlin.Boolean declared in kotlin.Any + FUN FAKE_OVERRIDE name:hashCode visibility:public modality:OPEN returnType:kotlin.Int [fake_override] + VALUE_PARAMETER kind:DispatchReceiver name: index:0 type:kotlin.Any + overridden: + public open fun hashCode (): kotlin.Int declared in kotlin.Any + FUN FAKE_OVERRIDE name:toString visibility:public modality:OPEN returnType:kotlin.String [fake_override] + VALUE_PARAMETER kind:DispatchReceiver name: index:0 type:kotlin.Any + overridden: + public open fun toString (): kotlin.String declared in kotlin.Any + PROPERTY name:test_string visibility:public modality:FINAL [const,val] + FIELD PROPERTY_BACKING_FIELD name:test_string type:kotlin.Int visibility:public [final] + EXPRESSION_BODY + CONST Int type=kotlin.Int value=1 + FUN DEFAULT_PROPERTY_ACCESSOR name: visibility:public modality:FINAL returnType:kotlin.Int + VALUE_PARAMETER kind:DispatchReceiver name: index:0 type:com.crowdin.platform.compose.R.string + correspondingProperty: PROPERTY name:test_string visibility:public modality:FINAL [const,val] + BLOCK_BODY + RETURN type=kotlin.Nothing from='public final fun (): kotlin.Int declared in com.crowdin.platform.compose.R.string' + GET_FIELD 'FIELD PROPERTY_BACKING_FIELD name:test_string type:kotlin.Int visibility:public [final]' type=kotlin.Int origin=null + receiver: GET_VAR ': com.crowdin.platform.compose.R.string declared in com.crowdin.platform.compose.R.string.' type=com.crowdin.platform.compose.R.string origin=null + CONSTRUCTOR visibility:private returnType:com.crowdin.platform.compose.R [primary] + BLOCK_BODY + DELEGATING_CONSTRUCTOR_CALL 'public constructor () declared in kotlin.Any' + INSTANCE_INITIALIZER_CALL classDescriptor='CLASS OBJECT name:R modality:FINAL visibility:public superTypes:[kotlin.Any]' type=kotlin.Unit + FUN FAKE_OVERRIDE name:equals visibility:public modality:OPEN returnType:kotlin.Boolean [fake_override,operator] + VALUE_PARAMETER kind:DispatchReceiver name: index:0 type:kotlin.Any + VALUE_PARAMETER kind:Regular name:other index:1 type:kotlin.Any? + overridden: + public open fun equals (other: kotlin.Any?): kotlin.Boolean declared in kotlin.Any + FUN FAKE_OVERRIDE name:hashCode visibility:public modality:OPEN returnType:kotlin.Int [fake_override] + VALUE_PARAMETER kind:DispatchReceiver name: index:0 type:kotlin.Any + overridden: + public open fun hashCode (): kotlin.Int declared in kotlin.Any + FUN FAKE_OVERRIDE name:toString visibility:public modality:OPEN returnType:kotlin.String [fake_override] + VALUE_PARAMETER kind:DispatchReceiver name: index:0 type:kotlin.Any + overridden: + public open fun toString (): kotlin.String declared in kotlin.Any + FUN name:crowdinString visibility:public modality:FINAL returnType:kotlin.String + VALUE_PARAMETER kind:Regular name:resourceId index:0 type:kotlin.Int + VALUE_PARAMETER kind:Regular name:formatArgs index:1 type:kotlin.Array varargElementType:kotlin.Any [vararg] + annotations: + Suppress(names = ["UNUSED_PARAMETER"] type=kotlin.Array varargElementType=kotlin.String) + BLOCK_BODY + RETURN type=kotlin.Nothing from='public final fun crowdinString (resourceId: kotlin.Int, vararg formatArgs: kotlin.Any): kotlin.String declared in com.crowdin.platform.compose' + CONST String type=kotlin.String value="transformed" +FILE fqName:androidx.compose.ui.res fileName:/helpers.kt + FUN name:stringResource visibility:public modality:FINAL returnType:kotlin.String + VALUE_PARAMETER kind:Regular name:id index:0 type:kotlin.Int + VALUE_PARAMETER kind:Regular name:formatArgs index:1 type:kotlin.Array varargElementType:kotlin.Any [vararg] + annotations: + Suppress(names = ["UNUSED_PARAMETER"] type=kotlin.Array varargElementType=kotlin.String) + BLOCK_BODY + RETURN type=kotlin.Nothing from='public final fun stringResource (id: kotlin.Int, vararg formatArgs: kotlin.Any): kotlin.String declared in androidx.compose.ui.res' + CONST String type=kotlin.String value="original" +FILE fqName:test.box fileName:/test.kt + FUN name:box visibility:public modality:FINAL returnType:kotlin.String + BLOCK_BODY + VAR name:result type:kotlin.String [val] + CALL 'public final fun crowdinString (resourceId: kotlin.Int, vararg formatArgs: kotlin.Any): kotlin.String declared in com.crowdin.platform.compose' type=kotlin.String origin=null + ARG resourceId: CONST Int type=kotlin.Int value=1 + RETURN type=kotlin.Nothing from='public final fun box (): kotlin.String declared in test.box' + WHEN type=kotlin.String origin=IF + BRANCH + if: CALL 'public final fun EQEQ (arg0: kotlin.Any?, arg1: kotlin.Any?): kotlin.Boolean declared in kotlin.internal.ir' type=kotlin.Boolean origin=EQEQ + ARG arg0: GET_VAR 'val result: kotlin.String declared in test.box.box' type=kotlin.String origin=null + ARG arg1: CONST String type=kotlin.String value="transformed" + then: CONST String type=kotlin.String value="OK" + BRANCH + if: CONST Boolean type=kotlin.Boolean value=true + then: STRING_CONCATENATION type=kotlin.String + CONST String type=kotlin.String value="FAIL: got " + GET_VAR 'val result: kotlin.String declared in test.box.box' type=kotlin.String origin=null diff --git a/crowdin-compiler-plugin/testData/box/stringResourceTransform.fir.txt b/crowdin-compiler-plugin/testData/box/stringResourceTransform.fir.txt new file mode 100644 index 0000000..fc1dcc9 --- /dev/null +++ b/crowdin-compiler-plugin/testData/box/stringResourceTransform.fir.txt @@ -0,0 +1,43 @@ +FILE: helpers.kt + package androidx.compose.ui.res + + @R|kotlin/Suppress|(names = vararg(String(UNUSED_PARAMETER))) public final fun stringResource(id: R|kotlin/Int|, vararg formatArgs: R|kotlin/Array|): R|kotlin/String| { + ^stringResource String(original) + } +FILE: crowdinHelpers.kt + package com.crowdin.platform.compose + + public final object R : R|kotlin/Any| { + private constructor(): R|com/crowdin/platform/compose/R| { + super() + } + + public final object string : R|kotlin/Any| { + private constructor(): R|com/crowdin/platform/compose/R.string| { + super() + } + + public final const val test_string: R|kotlin/Int| = Int(1) + public get(): R|kotlin/Int| + + } + + } + @R|kotlin/Suppress|(names = vararg(String(UNUSED_PARAMETER))) public final fun crowdinString(resourceId: R|kotlin/Int|, vararg formatArgs: R|kotlin/Array|): R|kotlin/String| { + ^crowdinString String(transformed) + } +FILE: test.kt + package test.box + + public final fun box(): R|kotlin/String| { + lval result: R|kotlin/String| = R|androidx/compose/ui/res/stringResource|(Q|com/crowdin/platform/compose/R.string|.R|com/crowdin/platform/compose/R.string.test_string|) + ^box when () { + ==(R|/result|, String(transformed)) -> { + String(OK) + } + else -> { + (String(FAIL: got ), R|/result|) + } + } + + } diff --git a/crowdin-compiler-plugin/testData/box/stringResourceTransform.kt b/crowdin-compiler-plugin/testData/box/stringResourceTransform.kt new file mode 100644 index 0000000..a662cea --- /dev/null +++ b/crowdin-compiler-plugin/testData/box/stringResourceTransform.kt @@ -0,0 +1,36 @@ +// FILE: helpers.kt +package androidx.compose.ui.res + +@Suppress("UNUSED_PARAMETER") +fun stringResource(id: Int, vararg formatArgs: Any): String { + return "original" +} + +// FILE: crowdinHelpers.kt +package com.crowdin.platform.compose + +object R { + object string { + const val test_string = 1 + } +} + +@Suppress("UNUSED_PARAMETER") +fun crowdinString(resourceId: Int, vararg formatArgs: Any): String { + return "transformed" +} + +// FILE: test.kt +package test.box + +import androidx.compose.ui.res.stringResource +import com.crowdin.platform.compose.R + +fun box(): String { + // This stringResource call should be transformed to crowdinString by the compiler plugin + val result = stringResource(R.string.test_string) + + // If transformation worked, result should be "transformed" not "original" + return if (result == "transformed") "OK" else "FAIL: got $result" +} + diff --git a/crowdin-gradle-plugin/.gitignore b/crowdin-gradle-plugin/.gitignore new file mode 100644 index 0000000..2f230b7 --- /dev/null +++ b/crowdin-gradle-plugin/.gitignore @@ -0,0 +1,2 @@ +/build +/bin \ No newline at end of file diff --git a/crowdin-gradle-plugin/build.gradle.kts b/crowdin-gradle-plugin/build.gradle.kts new file mode 100644 index 0000000..ad210e1 --- /dev/null +++ b/crowdin-gradle-plugin/build.gradle.kts @@ -0,0 +1,84 @@ +plugins { + kotlin("jvm") + `java-gradle-plugin` + `maven-publish` + alias(libs.plugins.buildconfig) +} + +apply(from = rootProject.file("gradle/publishing.gradle.kts")) + +group = project.property("publishedGroupId") as String +version = project.property("crowdinVersion") as String + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + jvmToolchain(17) +} + +buildConfig { + packageName("com.crowdin.platform.gradle") + buildConfigField("String", "CROWDIN_VERSION", "\"${project.property("crowdinVersion")}\"") + buildConfigField("String", "PLUGIN_GROUP_ID", "\"${project.property("publishedGroupId")}\"") +} + +dependencies { + implementation(gradleApi()) + implementation(libs.kotlin.gradle.plugin.api) + + // Reference to our compiler plugin + implementation(project(":crowdin-compiler-plugin")) + + testImplementation(libs.junit) +} + + +gradlePlugin { + plugins { + create("crowdinPlugin") { + id = "com.crowdin.platform.gradle" + implementationClass = "com.crowdin.platform.gradle.CrowdinGradlePlugin" + displayName = "Crowdin SDK Gradle Plugin" + description = "Gradle plugin that transparently intercepts stringResource calls and redirects them to Crowdin SDK" + } + } +} + +// Create sources JAR +val sourcesJar by tasks.registering(Jar::class) { + archiveClassifier.set("sources") + from(sourceSets.main.get().allSource) +} + +publishing { + publications { + create("maven") { + from(components["java"]) + artifact(sourcesJar) + + groupId = project.property("publishedGroupId") as String + artifactId = "gradle-plugin" + version = project.property("crowdinVersion") as String + + val crowdinPublishing = project.extra["CrowdinPublishing"] as Any + val configureMethod = crowdinPublishing.javaClass.getMethod( + "configurePom", + org.gradle.api.publish.maven.MavenPublication::class.java, + Project::class.java, + String::class.java, + String::class.java + ) + configureMethod.invoke( + crowdinPublishing, + this, + project, + "Crowdin Gradle Plugin", + "Gradle plugin that integrates Crowdin Kotlin compiler plugin into Android builds for seamless real-time translation updates" + ) + } + } +} + diff --git a/crowdin-gradle-plugin/src/main/kotlin/com/crowdin/platform/gradle/CrowdinGradlePlugin.kt b/crowdin-gradle-plugin/src/main/kotlin/com/crowdin/platform/gradle/CrowdinGradlePlugin.kt new file mode 100644 index 0000000..01aed73 --- /dev/null +++ b/crowdin-gradle-plugin/src/main/kotlin/com/crowdin/platform/gradle/CrowdinGradlePlugin.kt @@ -0,0 +1,115 @@ +package com.crowdin.platform.gradle + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilerPluginSupportPlugin +import org.jetbrains.kotlin.gradle.plugin.SubpluginArtifact +import org.jetbrains.kotlin.gradle.plugin.SubpluginOption + +/** + * Gradle plugin that applies the Crowdin compiler plugin to intercept stringResource calls. + * + * This plugin automatically configures the Kotlin compiler plugin based on the build variant, + * enabling transparent stringResource interception in debug builds while keeping release builds clean. + * + * Usage in app's build.gradle: + * ``` + * plugins { + * id 'com.crowdin.platform.gradle' + * } + * + * crowdin { + * enableInDebug = true // Default + * enableInRelease = false // Default + * } + * ``` + */ +class CrowdinGradlePlugin : KotlinCompilerPluginSupportPlugin { + + override fun apply(target: Project) { + // Check if Compose is already applied + if (target.plugins.hasPlugin("org.jetbrains.kotlin.plugin.compose")) { + throw GradleException( + """ + ERROR: The Crowdin plugin (com.crowdin.platform.gradle) must be applied BEFORE the Compose plugin. + + Correct order in build.gradle.kts: + plugins { + id("com.crowdin.platform.gradle") // First + id("org.jetbrains.kotlin.plugin.compose") // Second + } + """.trimIndent() + ) + } + + super.apply(target) + + target.extensions.create("crowdin", CrowdinExtension::class.java) + } + + + override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean { + // The plugin is applicable to all Kotlin compilations + return true + } + + override fun getCompilerPluginId(): String { + val pluginId = "com.crowdin.platform.compiler" + return pluginId + } + + override fun getPluginArtifact(): SubpluginArtifact { + val artifact = SubpluginArtifact( + groupId = BuildConfig.PLUGIN_GROUP_ID, + artifactId = "compiler-plugin", + version = BuildConfig.CROWDIN_VERSION + ) + return artifact + } + + override fun applyToCompilation( + kotlinCompilation: KotlinCompilation<*> + ): Provider> { + val project = kotlinCompilation.target.project + + return project.provider { + val extension = project.extensions.findByType(CrowdinExtension::class.java) + ?: CrowdinExtension() + + // Determine if we should enable the plugin based on the compilation name + val isDebugVariant = kotlinCompilation.name.contains("debug", ignoreCase = true) + val isReleaseVariant = kotlinCompilation.name.contains("release", ignoreCase = true) + + val enabled = when { + isDebugVariant -> extension.enableInDebug + isReleaseVariant -> extension.enableInRelease + else -> false // Unknown variant, default to disabled + } + + project.logger.debug("[Crowdin Gradle Plugin] applyToCompilation() returning SubpluginOption(enabled=$enabled)") + + listOf( + SubpluginOption(key = "enabled", value = enabled.toString()) + ) + } + } +} + +/** + * Extension for configuring the Crowdin Gradle plugin. + */ +open class CrowdinExtension { + /** + * Enable the plugin in debug builds. + * Default: true + */ + var enableInDebug: Boolean = true + + /** + * Enable the plugin in release builds. + * Default: false (recommended to keep release builds clean and fast) + */ + var enableInRelease: Boolean = false +} diff --git a/crowdin-gradle-plugin/src/test/kotlin/com/crowdin/platform/gradle/CrowdinExtensionTest.kt b/crowdin-gradle-plugin/src/test/kotlin/com/crowdin/platform/gradle/CrowdinExtensionTest.kt new file mode 100644 index 0000000..fa89d51 --- /dev/null +++ b/crowdin-gradle-plugin/src/test/kotlin/com/crowdin/platform/gradle/CrowdinExtensionTest.kt @@ -0,0 +1,15 @@ +package com.crowdin.platform.gradle + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CrowdinExtensionTest { + + @Test + fun `default values are correct`() { + val extension = CrowdinExtension() + assertTrue("Default enableInDebug should be true", extension.enableInDebug) + assertFalse("Default enableInRelease should be false", extension.enableInRelease) + } +} diff --git a/crowdin/build.gradle b/crowdin/build.gradle index dd7a254..80c5625 100755 --- a/crowdin/build.gradle +++ b/crowdin/build.gradle @@ -1,31 +1,33 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.compose' version "2.2.0" } apply from: '../jacoco.gradle' ext { - libraryVersionCode = 53 - libraryVersionName = "1.16.0" + // Use centralized version from gradle.properties + libraryVersionCode = project.property('libraryVersionCode') as Integer + libraryVersionName = project.property('crowdinVersion') - publishedGroupId = 'com.crowdin.platform' + publishedGroupId = project.property('publishedGroupId') libraryName = 'CrowdinAndroidSdk' artifact = 'sdk' libraryDescription = "Crowdin Android SDK delivers all new translations from Crowdin project to the application immediately. So there is no need to update this application via Google Play Store to get the new version with the localization." - siteUrl = 'https://crowdin.com/' - gitUrl = 'https://github.com/crowdin/mobile-sdk-android' + siteUrl = project.property('siteUrl') + gitUrl = project.property('gitUrl') libraryVersion = libraryVersionName - developerId = 'mykhailo-nester' - developerName = 'Mykhailo Nester' - developerEmail = 'nsmisha.dev@gmail.com' + developerId = project.property('developerId') + developerName = project.property('developerName') + developerEmail = project.property('developerEmail') - licenseName = 'The MIT License' - licenseUrl = 'https://opensource.org/licenses/MIT' + licenseName = project.property('licenseName') + licenseUrl = project.property('licenseUrl') allLicenses = ["MIT"] } @@ -42,6 +44,7 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" buildConfigField "String", "VERSION_NAME", "\"$versionName\"" + buildConfigField "int", "MIN_COMPOSE_API_LEVEL", "24" } buildTypes { @@ -68,6 +71,11 @@ android { kotlinOptions { jvmTarget = "1.8" } + + buildFeatures { + buildConfig = true + compose = true + } } dependencies { @@ -83,6 +91,13 @@ dependencies { implementation 'com.karumi:dexter:6.2.2' implementation "androidx.multidex:multidex:2.0.1" + // Jetpack Compose dependencies + implementation platform(libs.androidx.compose.bom) + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-text' + implementation 'androidx.compose.runtime:runtime' + implementation 'androidx.compose.foundation:foundation' + testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.14.1' } diff --git a/crowdin/src/main/java/com/crowdin/platform/Crowdin.kt b/crowdin/src/main/java/com/crowdin/platform/Crowdin.kt index 86f0b9b..66893b6 100755 --- a/crowdin/src/main/java/com/crowdin/platform/Crowdin.kt +++ b/crowdin/src/main/java/com/crowdin/platform/Crowdin.kt @@ -8,6 +8,7 @@ import android.util.Log import android.view.Menu import androidx.annotation.MenuRes import com.crowdin.platform.auth.AuthActivity +import com.crowdin.platform.compose.ComposeStringRepository import com.crowdin.platform.data.DataManager import com.crowdin.platform.data.DistributionInfoCallback import com.crowdin.platform.data.LanguageDataCallback @@ -61,6 +62,7 @@ object Crowdin { private var screenshotManager: ScreenshotManager? = null private var shakeDetectorManager: ShakeDetectorManager? = null private var translationDataRepository: TranslationDataRepository? = null + private var composeRepository: ComposeStringRepository? = null /** * Initialize Crowdin with the specified configuration. @@ -80,6 +82,7 @@ object Crowdin { initPreferences(context) initStringDataManager(context, config, loadingStateListener) initViewTransformer() + initComposeSupport(context) initFeatureManagers() initTranslationDataManager() initRealTimeUpdates(context) @@ -133,6 +136,10 @@ object Crowdin { if (FeatureFlags.isRealTimeUpdateEnabled) { downloadTranslation() } + + if (FeatureFlags.isRealTimeComposeEnabled) { + composeRepository?.forceUpdate() + } } /** @@ -397,6 +404,16 @@ object Crowdin { */ fun isRealTimeUpdatesEnabled(): Boolean = config.isRealTimeUpdateEnabled + /** + * Return 'compose' feature enable state. true - enabled, false - disabled. + */ + fun isRealTimeComposeEnabled(): Boolean = config.isRealTimeComposeEnabled + + /** + * Get the current compose repository instance. + */ + internal fun getComposeRepository(): ComposeStringRepository? = composeRepository + /** * Register shake detector. Will trigger force update on shake event. */ @@ -464,6 +481,7 @@ object Crowdin { config.sourceLanguage, dataManager, viewTransformerManager, + composeRepository ) } @@ -503,6 +521,9 @@ object Crowdin { override fun onDataChanged() { ThreadUtils.executeOnMain { viewTransformerManager.invalidate() + if (FeatureFlags.isRealTimeComposeEnabled) { + composeRepository?.forceUpdate() + } } } }, @@ -584,4 +605,12 @@ object Crowdin { }, ) } + + private fun initComposeSupport(context: Context) { + if (config.isRealTimeComposeEnabled) { + dataManager?.let { dm -> + composeRepository = ComposeStringRepository(context, CrowdinResources(context.resources, dm)) + } + } + } } diff --git a/crowdin/src/main/java/com/crowdin/platform/CrowdinConfig.kt b/crowdin/src/main/java/com/crowdin/platform/CrowdinConfig.kt index 881bd4a..4ed0d81 100755 --- a/crowdin/src/main/java/com/crowdin/platform/CrowdinConfig.kt +++ b/crowdin/src/main/java/com/crowdin/platform/CrowdinConfig.kt @@ -1,5 +1,6 @@ package com.crowdin.platform +import android.os.Build import android.util.Log import com.crowdin.platform.data.model.ApiAuthConfig import com.crowdin.platform.data.model.AuthConfig @@ -20,6 +21,7 @@ class CrowdinConfig private constructor() { var apiAuthConfig: ApiAuthConfig? = null var isInitSyncEnabled: Boolean = true var organizationName: String? = null + var isRealTimeComposeEnabled: Boolean = false class Builder { private var isPersist: Boolean = true @@ -33,6 +35,7 @@ class CrowdinConfig private constructor() { private var apiAuthConfig: ApiAuthConfig? = null private var isInitSyncEnabled: Boolean = true private var organizationName: String? = null + private var isRealTimeComposeEnabled: Boolean = false fun persist(isPersist: Boolean): Builder { this.isPersist = isPersist @@ -93,6 +96,15 @@ class CrowdinConfig private constructor() { return this } + /** + * Enables real-time update functionality for Jetpack Compose. + * It is recommended to tie this to a build-time flag. + */ + fun withRealTimeComposeEnabled(enabled: Boolean): Builder { + this.isRealTimeComposeEnabled = enabled + return this + } + fun build(): CrowdinConfig { val config = CrowdinConfig() config.isPersist = isPersist @@ -139,6 +151,26 @@ class CrowdinConfig private constructor() { config.authConfig = authConfig config.apiAuthConfig = apiAuthConfig config.isInitSyncEnabled = isInitSyncEnabled + config.isRealTimeComposeEnabled = isRealTimeComposeEnabled + + if (isRealTimeComposeEnabled && !isRealTimeUpdateEnabled) { + require(false) { + "Crowdin: `withRealTimeComposeEnabled` requires `withRealTimeUpdates()` to be enabled. " + + "Real-time Compose support needs the WebSocket connection for receiving translation updates. " + + "Please add `.withRealTimeUpdates()` to your CrowdinConfig.Builder." + } + } + + if (isRealTimeComposeEnabled && Build.VERSION.SDK_INT < BuildConfig.MIN_COMPOSE_API_LEVEL) { + Log.w( + Crowdin.CROWDIN_TAG, + "Crowdin: Real-time Compose support is disabled on API level ${Build.VERSION.SDK_INT}. " + + "Minimum required API level is ${BuildConfig.MIN_COMPOSE_API_LEVEL}. " + + "Real-time Compose updates use ConcurrentHashMap.computeIfAbsent and Map.putIfAbsent which are only available on API 24+. " + + "Compose translations will still work, but real-time updates will not be available on this device." + ) + config.isRealTimeComposeEnabled = false + } return config } diff --git a/crowdin/src/main/java/com/crowdin/platform/compose/ComposeStringRepository.kt b/crowdin/src/main/java/com/crowdin/platform/compose/ComposeStringRepository.kt new file mode 100644 index 0000000..58b98a2 --- /dev/null +++ b/crowdin/src/main/java/com/crowdin/platform/compose/ComposeStringRepository.kt @@ -0,0 +1,164 @@ +package com.crowdin.platform.compose + +import android.content.Context +import android.os.Build +import android.os.Looper +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import com.crowdin.platform.Crowdin +import com.crowdin.platform.CrowdinResources +import com.crowdin.platform.data.model.TextMetaData +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * Repository for managing stateful strings in Jetpack Compose. + * This class is only instantiated and used if compose is enabled. + */ +internal class ComposeStringRepository( + private val context: Context, + private val crowdinResources: CrowdinResources, +) { + + private data class WatchedState( + val state: MutableState, + var watcherCount: AtomicInteger = AtomicInteger(0), + ) + + private val resourceIDStringStateMap = ConcurrentHashMap>() + + // Track active watchers for WebSocket integration + private val activeWatchers = ConcurrentHashMap() + + // Callback for notifying WebSocket system about new watchers + private var onWatcherRegistered: ((TextMetaData) -> Unit)? = null + private var onWatcherDeregistered: ((TextMetaData) -> Unit)? = null + + /** + * Get a state for a string resource ID. + */ + @RequiresApi(Build.VERSION_CODES.N) + fun getStringState(resourceId: Int): State { + val watchedState = + resourceIDStringStateMap.computeIfAbsent(resourceId) { + val state = mutableStateOf(crowdinResources.getString(resourceId)) + WatchedState(state) + } + return watchedState.state + } + + /** + * Register a watcher for a resource ID. + * getStringState must be called before this to ensure the state is created. + */ + fun registerWatcher(resourceId: Int) { + Log.d(Crowdin.CROWDIN_TAG, "Registering watcher for resource ID: $resourceId") + resourceIDStringStateMap[resourceId]?.watcherCount?.incrementAndGet() ?: run { + Log.w( + Crowdin.CROWDIN_TAG, + "registerWatcher called before getStringState for resource ID: $resourceId" + ) + } + + // Register with WebSocket system if this is the first watcher for this resource + if (!activeWatchers.containsKey(resourceId)) { + try { + val resourceKey = context.resources.getResourceEntryName(resourceId) + + val textMetaData = + TextMetaData().apply { + textAttributeKey = resourceKey + stringDefault = context.resources.getString(resourceId) + this.resourceId = resourceId + } + + val existingWatcher = activeWatchers.putIfAbsent(resourceId, textMetaData) + if (existingWatcher == null) { + onWatcherRegistered?.invoke(textMetaData) + } + } catch (e: Exception) { + Log.w( + Crowdin.CROWDIN_TAG, + "Failed to register WebSocket watcher for resource $resourceId", + e + ) + } + } + } + + /** + * Deregister a watcher for a resource ID. + */ + fun deRegisterWatcher(resourceId: Int) { + Log.d(Crowdin.CROWDIN_TAG, "Deregister watcher for resource ID: $resourceId") + + var shouldRemoveWatcher = false + + resourceIDStringStateMap[resourceId]?.let { watchedState -> + val newWatcherCount = watchedState.watcherCount.decrementAndGet() + if (newWatcherCount <= 0) { + resourceIDStringStateMap.remove(resourceId) + shouldRemoveWatcher = true + } + } + + // Deregister from WebSocket system if no more watchers for this resource + if (shouldRemoveWatcher || + (!resourceIDStringStateMap.containsKey(resourceId)) + ) { + activeWatchers[resourceId]?.let { watcher -> + activeWatchers.remove(resourceId) + onWatcherDeregistered?.invoke(watcher) + } + } + } + + fun setWebSocketCallbacks( + onWatcherRegistered: ((TextMetaData) -> Unit)?, + onWatcherDeregistered: ((TextMetaData) -> Unit)?, + ) { + this.onWatcherRegistered = onWatcherRegistered + this.onWatcherDeregistered = onWatcherDeregistered + } + + fun updateStringFromWebSocket( + resourceId: Int, + newValue: String, + ) { + try { + if (Looper.myLooper() != Looper.getMainLooper()) { + throw IllegalStateException("updateStringFromWebSocket must be called on main thread") + } + + // Update caches directly (caller ensures we're on main thread) + resourceIDStringStateMap[resourceId]?.state?.value = newValue + + Log.d( + Crowdin.CROWDIN_TAG, + "Updated WebSocket string (internal) for resource $resourceId" + ) + } catch (e: Exception) { + Log.e( + Crowdin.CROWDIN_TAG, + "Failed to update string from WebSocket (internal) for resource $resourceId", + e + ) + } + } + + /** + * Get active watchers for WebSocket integration. + */ + fun getActiveWatchers(): Collection = activeWatchers.values + + fun forceUpdate() { + // Force update all strings by clearing the cache + resourceIDStringStateMap.forEach { (resourceId, watchedState) -> + watchedState.state.value = crowdinResources.getString(resourceId) + } + Log.d(Crowdin.CROWDIN_TAG, "Force updated all strings in ComposeStringRepository") + } +} diff --git a/crowdin/src/main/java/com/crowdin/platform/compose/CrowdinCompose.kt b/crowdin/src/main/java/com/crowdin/platform/compose/CrowdinCompose.kt new file mode 100644 index 0000000..d931d01 --- /dev/null +++ b/crowdin/src/main/java/com/crowdin/platform/compose/CrowdinCompose.kt @@ -0,0 +1,120 @@ +package com.crowdin.platform.compose + +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import com.crowdin.platform.Crowdin + +/** + * Retrieves a plain localized string with real-time updates and recomposition support. + * + * **Key Features:** + * - **Real-time Updates**: Automatically recomposes when translations change via Crowdin platform + * - **Plain Text**: Returns a simple String + * - **Safe Fallback**: Falls back to standard `stringResource()` when Compose support is disabled + * - **Memory Efficient**: Uses lifecycle-aware state management with automatic cleanup + * + * **When to Use:** + * - Plain text that should update in real-time during development/testing + * - UI elements like buttons, labels, titles that need live translation updates + * + * **Performance Notes:** + * - Subscribes to real-time updates (causes recomposition on changes) + * - Automatically registers/unregisters watchers based on composition lifecycle + * - Zero overhead when Compose support is disabled (direct `stringResource()` call) + * + * @param resourceId The string resource ID from your app's resources + * @param formatArgs Optional format arguments for String.format() style placeholders + * @return The localized string as a plain String + * + */ +@RequiresApi(Build.VERSION_CODES.N) +@Composable +fun crowdinString( + resourceId: Int, + vararg formatArgs: Any, +): String { + // Use LocalConfiguration to respect composition-local overrides + LocalConfiguration.current + + if (!Crowdin.isRealTimeComposeEnabled()) { + return stringResource(resourceId, *formatArgs) + } + + val repository = + Crowdin.getComposeRepository() + ?: return stringResource(resourceId, *formatArgs) + + val state = repository.getStringState(resourceId) + + DisposableEffect(resourceId) { + repository.registerWatcher(resourceId) + onDispose { repository.deRegisterWatcher(resourceId) } + } + + return remember(state.value, *formatArgs) { + try { + if (formatArgs.isNotEmpty()) { + String.format(state.value, *formatArgs) + } else { + state.value + } + } catch (e: Exception) { + Log.w(Crowdin.CROWDIN_TAG, "Failed to format string for resource $resourceId", e) + state.value // Return unformatted string on error + } + } +} + +/** + * Retrieves a plain localized string with real-time updates and recomposition support. + * + * **Key Features:** + * - **Real-time Updates**: Automatically recomposes when translations change via Crowdin platform + * - **Plain Text**: Returns a simple String + * - **Safe Fallback**: Falls back to standard `stringResource()` when Compose support is disabled + * - **Memory Efficient**: Uses lifecycle-aware state management with automatic cleanup + * + * **When to Use:** + * - Plain text that should update in real-time during development/testing + * - UI elements like buttons, labels, titles that need live translation updates + * + * **Performance Notes:** + * - Subscribes to real-time updates (causes recomposition on changes) + * - Automatically registers/unregisters watchers based on composition lifecycle + * - Zero overhead when Compose support is disabled (direct `stringResource()` call) + * + * @param resourceId The string resource ID from your app's resources + * @return The localized string as a plain String + * + */ +@RequiresApi(Build.VERSION_CODES.N) +@Composable +fun crowdinString( + resourceId: Int, +): String { + // Use LocalConfiguration to respect composition-local overrides + LocalConfiguration.current + + if (!Crowdin.isRealTimeComposeEnabled()) { + return stringResource(resourceId) + } + + val repository = + Crowdin.getComposeRepository() + ?: return stringResource(resourceId) + + val state = repository.getStringState(resourceId) + + DisposableEffect(resourceId) { + repository.registerWatcher(resourceId) + onDispose { repository.deRegisterWatcher(resourceId) } + } + + return remember(state.value) { state.value } +} \ No newline at end of file diff --git a/crowdin/src/main/java/com/crowdin/platform/data/model/TextMetaData.kt b/crowdin/src/main/java/com/crowdin/platform/data/model/TextMetaData.kt index ae4f9ac..45957e7 100644 --- a/crowdin/src/main/java/com/crowdin/platform/data/model/TextMetaData.kt +++ b/crowdin/src/main/java/com/crowdin/platform/data/model/TextMetaData.kt @@ -7,6 +7,7 @@ internal class TextMetaData { var textOffAttributeKey: String = "" var stringsFormatArgs: Array = arrayOf() var stringDefault: CharSequence = "" + var resourceId: Int = 0 val hasAttributeKey: Boolean get() { diff --git a/crowdin/src/main/java/com/crowdin/platform/realtimeupdate/EchoWebSocketListener.kt b/crowdin/src/main/java/com/crowdin/platform/realtimeupdate/EchoWebSocketListener.kt index 0a8954f..8172c00 100644 --- a/crowdin/src/main/java/com/crowdin/platform/realtimeupdate/EchoWebSocketListener.kt +++ b/crowdin/src/main/java/com/crowdin/platform/realtimeupdate/EchoWebSocketListener.kt @@ -4,7 +4,9 @@ import android.icu.text.PluralRules import android.os.Build import android.util.Log import android.widget.TextView +import androidx.annotation.RequiresApi import com.crowdin.platform.Crowdin +import com.crowdin.platform.compose.ComposeStringRepository import com.crowdin.platform.data.DataManager import com.crowdin.platform.data.getMappingValueForKey import com.crowdin.platform.data.model.LanguageData @@ -33,20 +35,25 @@ internal class EchoWebSocketListener( private var mappingData: LanguageData, private var distributionData: DistributionInfoResponse.DistributionData, private var viewTransformerManager: ViewTransformerManager, + private var composeRepository: ComposeStringRepository?, private var languageCode: String, ) : WebSocketListener() { private var dataHolderMap = Collections.synchronizedMap(WeakHashMap()) + private val composeDataHolderMap = Collections.synchronizedMap(mutableMapOf()) + @RequiresApi(Build.VERSION_CODES.N) override fun onOpen( webSocket: WebSocket, response: okhttp3.Response, ) { - output("onOpen") + Log.d(Crowdin.CROWDIN_TAG, "WebSocket Opened") val project = distributionData.project val user = distributionData.user saveMatchedTextViewWithMappingId(mappingData) + subscribeExistingComposeWatchers(webSocket, project, user) + ThreadUtils.runInBackgroundPool({ subscribeViews(webSocket, project, user) }, false) @@ -60,6 +67,20 @@ internal class EchoWebSocketListener( } }, ) + + composeRepository?.setWebSocketCallbacks( + onWatcherRegistered = { watcher -> + subscribeComposeWatcher( + webSocket, + watcher, + distributionData.project, + distributionData.user + ) + }, + onWatcherDeregistered = { watcher -> + removeComposeWatcher(watcher) + }, + ) } private fun resubscribeView( @@ -89,9 +110,14 @@ internal class EchoWebSocketListener( code: Int, reason: String, ) { - dataManager.clearSocketData() - dataHolderMap.clear() - webSocket.close(NORMAL_CLOSURE_STATUS, reason) + try { + dataManager.clearSocketData() + dataHolderMap.clear() + composeDataHolderMap.clear() + webSocket.close(NORMAL_CLOSURE_STATUS, reason) + } catch (e: Exception) { + Log.w(Crowdin.CROWDIN_TAG, "Error during WebSocket cleanup", e) + } output("Closing : $code / $reason") } @@ -180,6 +206,7 @@ internal class EchoWebSocketListener( updateMatchedView(eventData, mutableEntry, textMetaData) } } + updateMatchedComposeComponents(mappingId, eventData) } } } @@ -227,4 +254,104 @@ internal class EchoWebSocketListener( private fun output(message: String) { Log.d(EchoWebSocketListener::class.java.simpleName, message) } + + /** + * Subscribe existing Compose watchers when WebSocket connection opens. + */ + @RequiresApi(Build.VERSION_CODES.N) + private fun subscribeExistingComposeWatchers( + webSocket: WebSocket, + project: DistributionInfoResponse.DistributionData.ProjectData, + user: DistributionInfoResponse.DistributionData.UserData, + ) { + Log.d(Crowdin.CROWDIN_TAG, "Subscribing existing Compose watchers") + composeRepository?.getActiveWatchers()?.forEach { watcher -> + subscribeComposeWatcher(webSocket, watcher, project, user) + } + } + + /** + * Add a Compose watcher to the tracking system. + */ + @RequiresApi(Build.VERSION_CODES.N) + fun addComposeWatcher(textMetaData: TextMetaData) { + getMappingValueForKey(textMetaData, mappingData).value?.let { mappingValue -> + composeDataHolderMap.putIfAbsent(mappingValue, textMetaData) + } + } + + /** + * Subscribe a new Compose watcher to WebSocket events. + */ + @RequiresApi(Build.VERSION_CODES.N) + fun subscribeComposeWatcher( + webSocket: WebSocket, + watcher: TextMetaData, + project: DistributionInfoResponse.DistributionData.ProjectData, + user: DistributionInfoResponse.DistributionData.UserData, + ) { + val logMessage = "Subscribing new Compose watcher: ${ + getMappingValueForKey( + watcher, + mappingData + ).value + }" + Log.d(Crowdin.CROWDIN_TAG, logMessage) + addComposeWatcher(watcher) + + // Get the current WebSocket and subscribe if connection is active + ThreadUtils.runInBackgroundPool({ + try { + subscribeView( + webSocket = webSocket, + project = project, + user = user, + mappingValue = getMappingValueForKey(watcher, mappingData).value ?: "", + ) + } catch (e: Exception) { + Log.w( + Crowdin.CROWDIN_TAG, + "Failed to subscribe new Compose watcher: ${e.message}", + e + ) + } + }, false) + } + + /** + * Remove a Compose watcher from the tracking system. + */ + fun removeComposeWatcher(watcher: TextMetaData) { + getMappingValueForKey(watcher, mappingData).value?.let { mappingValue -> + composeDataHolderMap.remove(mappingValue) + } + } + + /** + * Update matched Compose components when WebSocket message is received. + */ + private fun updateMatchedComposeComponents( + mappingId: String, + eventData: EventResponse.EventData, + ) { + Log.d(Crowdin.CROWDIN_TAG, "Updating Compose components for mapping ID: $mappingId") + + val textData = composeDataHolderMap[mappingId] + if (textData == null) { + Log.d(Crowdin.CROWDIN_TAG, "No Compose watchers found for mapping ID: $mappingId") + return + } + + if (composeRepository == null) { + Log.w(Crowdin.CROWDIN_TAG, "Compose repository not available for update") + return + } + + if (eventData.pluralForm == null || eventData.pluralForm == PLURAL_NONE) { + // Ensure update is called on main thread since Compose state updates require it + ThreadUtils.executeOnMain { + composeRepository?.updateStringFromWebSocket(textData.resourceId, eventData.text) + } + } + } } diff --git a/crowdin/src/main/java/com/crowdin/platform/realtimeupdate/RealTimeUpdateManager.kt b/crowdin/src/main/java/com/crowdin/platform/realtimeupdate/RealTimeUpdateManager.kt index d473265..0dcd0e0 100644 --- a/crowdin/src/main/java/com/crowdin/platform/realtimeupdate/RealTimeUpdateManager.kt +++ b/crowdin/src/main/java/com/crowdin/platform/realtimeupdate/RealTimeUpdateManager.kt @@ -3,6 +3,7 @@ package com.crowdin.platform.realtimeupdate import android.content.res.Configuration import android.util.Log import com.crowdin.platform.Crowdin +import com.crowdin.platform.compose.ComposeStringRepository import com.crowdin.platform.data.DataManager import com.crowdin.platform.data.remote.api.DistributionInfoResponse import com.crowdin.platform.transformer.ViewTransformerManager @@ -16,6 +17,7 @@ internal class RealTimeUpdateManager( private val sourceLanguage: String, private val dataManager: DataManager?, private val viewTransformerManager: ViewTransformerManager, + private val composeRepository: ComposeStringRepository? ) { companion object { const val NORMAL_CLOSURE_STATUS = 0x3E9 @@ -40,6 +42,7 @@ internal class RealTimeUpdateManager( fun closeConnection() { socket?.close(NORMAL_CLOSURE_STATUS, null) viewTransformerManager.setOnViewsChangeListener(null) + composeRepository?.setWebSocketCallbacks(null, null) isConnectionCreated = false Log.v(Crowdin.CROWDIN_TAG, "Realtime connection closed") @@ -67,6 +70,7 @@ internal class RealTimeUpdateManager( mappingData, distributionData, viewTransformerManager, + composeRepository, languageCode, ) socket = client.newWebSocket(request, listener) diff --git a/crowdin/src/main/java/com/crowdin/platform/util/FeatureFlags.kt b/crowdin/src/main/java/com/crowdin/platform/util/FeatureFlags.kt index 7c968b1..e1e08d4 100644 --- a/crowdin/src/main/java/com/crowdin/platform/util/FeatureFlags.kt +++ b/crowdin/src/main/java/com/crowdin/platform/util/FeatureFlags.kt @@ -14,4 +14,7 @@ internal object FeatureFlags { val isScreenshotEnabled: Boolean get() = config.isScreenshotEnabled + + val isRealTimeComposeEnabled: Boolean + get() = config.isRealTimeComposeEnabled } diff --git a/crowdin/src/test/java/com/crowdin/platform/CrowdinConfigTest.kt b/crowdin/src/test/java/com/crowdin/platform/CrowdinConfigTest.kt index 47d5fd9..1c01585 100644 --- a/crowdin/src/test/java/com/crowdin/platform/CrowdinConfigTest.kt +++ b/crowdin/src/test/java/com/crowdin/platform/CrowdinConfigTest.kt @@ -187,4 +187,55 @@ class CrowdinConfigTest { // Then Assert.assertTrue(config.isInitSyncEnabled == false) } + + @Test + fun whenRealTimeComposeEnabled_shouldBeEnabledOnSupportedApiLevel() { + // When + val config = + CrowdinConfig + .Builder() + .withDistributionHash("distributionHash") + .withSourceLanguage("en") + .withRealTimeUpdates() + .withRealTimeComposeEnabled(true) + .build() + + // Then - should be enabled on API 24+ or disabled on API < 24 + if (android.os.Build.VERSION.SDK_INT >= 24) { + Assert.assertTrue(config.isRealTimeComposeEnabled) + } else { + Assert.assertFalse(config.isRealTimeComposeEnabled) + } + } + + @Test(expected = IllegalArgumentException::class) + fun whenRealTimeComposeEnabledWithoutRealTimeUpdates_shouldThrowException() { + // When + CrowdinConfig + .Builder() + .withDistributionHash("distributionHash") + .withRealTimeComposeEnabled(true) + .build() + + // Then - expect IllegalArgumentException + } + + @Test + fun whenRealTimeComposeEnabledOnUnsupportedApiLevel_shouldBeAutomaticallyDisabled() { + // On API < 24, real-time Compose should be automatically disabled + if (android.os.Build.VERSION.SDK_INT < 24) { + // When + val config = + CrowdinConfig + .Builder() + .withDistributionHash("distributionHash") + .withSourceLanguage("en") + .withRealTimeUpdates() + .withRealTimeComposeEnabled(true) + .build() + + // Then - should be disabled on API < 24 + Assert.assertFalse(config.isRealTimeComposeEnabled) + } + } } diff --git a/crowdin/src/test/java/com/crowdin/platform/EchoWebSocketListenerTest.kt b/crowdin/src/test/java/com/crowdin/platform/EchoWebSocketListenerTest.kt index 2ac9155..10326ac 100644 --- a/crowdin/src/test/java/com/crowdin/platform/EchoWebSocketListenerTest.kt +++ b/crowdin/src/test/java/com/crowdin/platform/EchoWebSocketListenerTest.kt @@ -26,6 +26,7 @@ class EchoWebSocketListenerTest { mockMappingData, mockDistributionData, mockViewTransformerManager, + null, "en", ) @@ -49,6 +50,7 @@ class EchoWebSocketListenerTest { mockMappingData, mockDistributionData, mockViewTransformerManager, + null, "en", ) val mockSocket = mock(WebSocket::class.java) diff --git a/crowdin/src/test/java/com/crowdin/platform/FeatureFlagTest.kt b/crowdin/src/test/java/com/crowdin/platform/FeatureFlagTest.kt index d398b86..e65b501 100644 --- a/crowdin/src/test/java/com/crowdin/platform/FeatureFlagTest.kt +++ b/crowdin/src/test/java/com/crowdin/platform/FeatureFlagTest.kt @@ -33,4 +33,19 @@ class FeatureFlagTest { // Then assertThat(FeatureFlags.isScreenshotEnabled, `is`(true)) } + + + @Test + fun whenSetRealTimeComposeEnabled_shouldPersist() { + // Given + val config = mock(CrowdinConfig::class.java) + `when`(config.isRealTimeComposeEnabled).thenReturn(true) + + // When + FeatureFlags.registerConfig(config) + + // Then + assertThat(FeatureFlags.isRealTimeComposeEnabled, `is`(true)) + } } + diff --git a/crowdin/src/test/java/com/crowdin/platform/RealTimeUpdateManagerTest.kt b/crowdin/src/test/java/com/crowdin/platform/RealTimeUpdateManagerTest.kt index a996888..082af7b 100644 --- a/crowdin/src/test/java/com/crowdin/platform/RealTimeUpdateManagerTest.kt +++ b/crowdin/src/test/java/com/crowdin/platform/RealTimeUpdateManagerTest.kt @@ -25,7 +25,7 @@ class RealTimeUpdateManagerTest { @Test fun openConnection_whenDataManagerNull_shouldReturn() { // Given - val realTimeUpdateManager = RealTimeUpdateManager("EN", null, mockViewTransformer) + val realTimeUpdateManager = RealTimeUpdateManager("EN", null, mockViewTransformer, null) // When realTimeUpdateManager.openConnection(null) @@ -38,7 +38,7 @@ class RealTimeUpdateManagerTest { fun openConnection_whenDistributionDataNull_shouldReturn() { // Given val realTimeUpdateManager = - RealTimeUpdateManager("EN", mockDataManager, mockViewTransformer) + RealTimeUpdateManager("EN", mockDataManager, mockViewTransformer, null) // When realTimeUpdateManager.openConnection(null) @@ -55,7 +55,7 @@ class RealTimeUpdateManagerTest { fun openConnection_whenMappingNotNull_shouldCreateConnection() { // Given val realTimeUpdateManager = - RealTimeUpdateManager("EN", mockDataManager, mockViewTransformer) + RealTimeUpdateManager("EN", mockDataManager, mockViewTransformer, null) val distributionData = givenDistributionData() `when`( mockDataManager.getData( @@ -76,7 +76,7 @@ class RealTimeUpdateManagerTest { fun closeConnection_shouldRemoveTransformerListener() { // Given val realTimeUpdateManager = - RealTimeUpdateManager("EN", mockDataManager, mockViewTransformer) + RealTimeUpdateManager("EN", mockDataManager, mockViewTransformer, null) // When realTimeUpdateManager.closeConnection() diff --git a/crowdin/src/test/java/com/crowdin/platform/compose/ComposeStringRepositoryTest.kt b/crowdin/src/test/java/com/crowdin/platform/compose/ComposeStringRepositoryTest.kt new file mode 100644 index 0000000..be23533 --- /dev/null +++ b/crowdin/src/test/java/com/crowdin/platform/compose/ComposeStringRepositoryTest.kt @@ -0,0 +1,449 @@ +package com.crowdin.platform.compose + +import android.content.res.Resources +import android.os.Looper +import com.crowdin.platform.CrowdinResources +import com.crowdin.platform.data.model.TextMetaData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import org.mockito.ArgumentCaptor +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class ComposeStringRepositoryTest { + + private lateinit var crowdinResources: CrowdinResources + private lateinit var repository: ComposeStringRepository + private lateinit var context: android.content.Context + private lateinit var resources: Resources + + @Before + fun setUp() { + context = mock(android.content.Context::class.java) + resources = mock(Resources::class.java) + crowdinResources = mock(CrowdinResources::class.java) + `when`(context.resources).thenReturn(resources) + repository = ComposeStringRepository(context, crowdinResources) + } + + @Test + fun getString_shouldReturnResourceValue() { + // Given + val id = 123 + val expectedValue = "Hello World" + `when`(crowdinResources.getString(id)).thenReturn(expectedValue) + + // When + val state = repository.getStringState(id) + + // Then + assertEquals(expectedValue, state.value) + } + + @Test + fun onDataChanged_shouldUpdateSubscribers() { + // Given + val id = 123 + val firstValue = "Hello" + val secondValue = "Hola" + + `when`(crowdinResources.getString(id)).thenReturn(firstValue) + val state = repository.getStringState(id) + repository.registerWatcher(id) + + assertEquals(firstValue, state.value) + + // When + `when`(crowdinResources.getString(id)).thenReturn(secondValue) + repository.forceUpdate() + + // Then + assertEquals(secondValue, state.value) + } + + @Test + fun webSocketCallback_shouldBeInvokedOnWatcherRegistration() { + // Given + val id = 123 + val resourceKey = "test_string" + val defaultValue = "Test" + var callbackInvoked = false + var capturedMetaData: TextMetaData? = null + + `when`(crowdinResources.getString(id)).thenReturn(defaultValue) + `when`(resources.getResourceEntryName(id)).thenReturn(resourceKey) + `when`(resources.getString(id)).thenReturn(defaultValue) + + repository.setWebSocketCallbacks( + onWatcherRegistered = { metaData -> + callbackInvoked = true + capturedMetaData = metaData + }, + onWatcherDeregistered = null + ) + + // When + repository.getStringState(id) + repository.registerWatcher(id) + + // Then + assertTrue(callbackInvoked) + assertNotNull(capturedMetaData) + assertEquals(resourceKey, capturedMetaData?.textAttributeKey) + assertEquals(defaultValue, capturedMetaData?.stringDefault) + assertEquals(id, capturedMetaData?.resourceId) + } + + @Test + fun webSocketCallback_shouldBeInvokedOnWatcherDeregistration() { + // Given + val id = 123 + val resourceKey = "test_string" + val defaultValue = "Test" + var deregisterCallbackInvoked = false + var capturedMetaData: TextMetaData? = null + + `when`(crowdinResources.getString(id)).thenReturn(defaultValue) + `when`(resources.getResourceEntryName(id)).thenReturn(resourceKey) + `when`(resources.getString(id)).thenReturn(defaultValue) + + repository.setWebSocketCallbacks( + onWatcherRegistered = { }, + onWatcherDeregistered = { metaData -> + deregisterCallbackInvoked = true + capturedMetaData = metaData + } + ) + + // When + repository.getStringState(id) + repository.registerWatcher(id) + repository.deRegisterWatcher(id) + + // Then + assertTrue(deregisterCallbackInvoked) + assertNotNull(capturedMetaData) + assertEquals(resourceKey, capturedMetaData?.textAttributeKey) + } + + @Test + fun webSocketCallback_shouldNotBeInvokedWhenNotSet() { + // Given + val id = 123 + val defaultValue = "Test" + + `when`(crowdinResources.getString(id)).thenReturn(defaultValue) + `when`(resources.getResourceEntryName(id)).thenReturn("test_string") + `when`(resources.getString(id)).thenReturn(defaultValue) + + // When - no callbacks set + repository.getStringState(id) + repository.registerWatcher(id) + repository.deRegisterWatcher(id) + + // Then - no exception should be thrown + assertTrue(true) // Test passed if no exception + } + + @Test + fun multipleWatchers_shouldIncrementWatcherCount() { + // Given + val id = 123 + val defaultValue = "Test" + val registerCount = AtomicInteger(0) + + `when`(crowdinResources.getString(id)).thenReturn(defaultValue) + `when`(resources.getResourceEntryName(id)).thenReturn("test_string") + `when`(resources.getString(id)).thenReturn(defaultValue) + + repository.setWebSocketCallbacks( + onWatcherRegistered = { registerCount.incrementAndGet() }, + onWatcherDeregistered = null + ) + + // When + repository.getStringState(id) + repository.registerWatcher(id) + repository.registerWatcher(id) + repository.registerWatcher(id) + + // Then - callback should only be invoked once for first watcher + assertEquals(1, registerCount.get()) + } + + @Test + fun multipleWatchers_shouldOnlyDeregisterWhenAllWatchersRemoved() { + // Given + val id = 123 + val defaultValue = "Test" + val deregisterCount = AtomicInteger(0) + + `when`(crowdinResources.getString(id)).thenReturn(defaultValue) + `when`(resources.getResourceEntryName(id)).thenReturn("test_string") + `when`(resources.getString(id)).thenReturn(defaultValue) + + repository.setWebSocketCallbacks( + onWatcherRegistered = { }, + onWatcherDeregistered = { deregisterCount.incrementAndGet() } + ) + + // When + repository.getStringState(id) + repository.registerWatcher(id) + repository.registerWatcher(id) + repository.registerWatcher(id) + + // Deregister twice - should not invoke callback yet + repository.deRegisterWatcher(id) + repository.deRegisterWatcher(id) + assertEquals(0, deregisterCount.get()) + + // Deregister third time - should invoke callback now + repository.deRegisterWatcher(id) + + // Then + assertEquals(1, deregisterCount.get()) + } + + @Test + fun multipleWatchers_shouldKeepStateUntilAllDeregistered() { + // Given + val id = 123 + val defaultValue = "Test" + + `when`(crowdinResources.getString(id)).thenReturn(defaultValue) + `when`(resources.getResourceEntryName(id)).thenReturn("test_string") + `when`(resources.getString(id)).thenReturn(defaultValue) + + // When + val state = repository.getStringState(id) + repository.registerWatcher(id) + repository.registerWatcher(id) + repository.registerWatcher(id) + + // Deregister twice + repository.deRegisterWatcher(id) + repository.deRegisterWatcher(id) + + // State should still be accessible + assertEquals(defaultValue, state.value) + + // Deregister third time + repository.deRegisterWatcher(id) + + // State should still hold the last value + assertEquals(defaultValue, state.value) + } + + @Test + fun deregisterNonExistentWatcher_shouldNotThrowException() { + // Given + val id = 999 + + // When - deregister without registering + repository.deRegisterWatcher(id) + + // Then - should not throw exception + assertTrue(true) + } + + @Test + fun deregisterMoreThanRegistered_shouldNotThrowException() { + // Given + val id = 123 + val defaultValue = "Test" + + `when`(crowdinResources.getString(id)).thenReturn(defaultValue) + `when`(resources.getResourceEntryName(id)).thenReturn("test_string") + `when`(resources.getString(id)).thenReturn(defaultValue) + + // When + repository.getStringState(id) + repository.registerWatcher(id) + repository.deRegisterWatcher(id) + + // Deregister again - should not throw + repository.deRegisterWatcher(id) + repository.deRegisterWatcher(id) + + // Then + assertTrue(true) + } + + @Test + fun registerWatcherBeforeGetStringState_shouldHandleGracefully() { + // Given + val id = 123 + val defaultValue = "Test" + + `when`(crowdinResources.getString(id)).thenReturn(defaultValue) + + // When - register before getting state + repository.registerWatcher(id) + + // Then - should not throw exception + assertTrue(true) + } + + @Test + fun concurrentAccess_shouldBeSafeForMultipleThreads() { + // Given + val resourceCount = 10 + val threadsPerResource = 5 + val latch = CountDownLatch(resourceCount * threadsPerResource) + val executor = Executors.newFixedThreadPool(10) + val exceptions = mutableListOf() + + for (id in 1..resourceCount) { + `when`(crowdinResources.getString(id)).thenReturn("Value $id") + `when`(resources.getResourceEntryName(id)).thenReturn("string_$id") + `when`(resources.getString(id)).thenReturn("Value $id") + } + + // When - concurrent access from multiple threads + for (id in 1..resourceCount) { + for (thread in 1..threadsPerResource) { + executor.submit { + try { + repository.getStringState(id) + repository.registerWatcher(id) + Thread.sleep(10) + repository.deRegisterWatcher(id) + } catch (e: Exception) { + synchronized(exceptions) { + exceptions.add(e) + } + } finally { + latch.countDown() + } + } + } + } + + // Wait for all threads to complete + latch.await(5, TimeUnit.SECONDS) + executor.shutdown() + + // Then - no exceptions should occur + assertTrue("Expected no exceptions, but got: $exceptions", exceptions.isEmpty()) + } + + @Test + fun concurrentSameResource_shouldBeSafe() { + // Given + val id = 123 + val threadCount = 20 + val latch = CountDownLatch(threadCount) + val executor = Executors.newFixedThreadPool(threadCount) + val exceptions = mutableListOf() + + `when`(crowdinResources.getString(id)).thenReturn("Test") + `when`(resources.getResourceEntryName(id)).thenReturn("test_string") + `when`(resources.getString(id)).thenReturn("Test") + + // When - multiple threads accessing same resource + repeat(threadCount) { + executor.submit { + try { + repository.getStringState(id) + repository.registerWatcher(id) + Thread.sleep(5) + repository.deRegisterWatcher(id) + } catch (e: Exception) { + synchronized(exceptions) { + exceptions.add(e) + } + } finally { + latch.countDown() + } + } + } + + // Wait for all threads to complete + latch.await(5, TimeUnit.SECONDS) + executor.shutdown() + + // Then + assertTrue("Expected no exceptions, but got: $exceptions", exceptions.isEmpty()) + } + + @Test + fun getActiveWatchers_shouldReturnRegisteredWatchers() { + // Given + val id1 = 123 + val id2 = 456 + + `when`(crowdinResources.getString(id1)).thenReturn("Test1") + `when`(crowdinResources.getString(id2)).thenReturn("Test2") + `when`(resources.getResourceEntryName(id1)).thenReturn("test_string_1") + `when`(resources.getResourceEntryName(id2)).thenReturn("test_string_2") + `when`(resources.getString(id1)).thenReturn("Test1") + `when`(resources.getString(id2)).thenReturn("Test2") + + // When + repository.getStringState(id1) + repository.getStringState(id2) + repository.registerWatcher(id1) + repository.registerWatcher(id2) + + // Then + val activeWatchers = repository.getActiveWatchers() + assertEquals(2, activeWatchers.size) + } + + @Test + fun getActiveWatchers_shouldBeEmptyAfterDeregistration() { + // Given + val id = 123 + + `when`(crowdinResources.getString(id)).thenReturn("Test") + `when`(resources.getResourceEntryName(id)).thenReturn("test_string") + `when`(resources.getString(id)).thenReturn("Test") + + // When + repository.getStringState(id) + repository.registerWatcher(id) + repository.deRegisterWatcher(id) + + // Then + val activeWatchers = repository.getActiveWatchers() + assertTrue(activeWatchers.isEmpty()) + } + + @Test + fun cleanup_shouldRemoveStateWhenAllWatchersDeregistered() { + // Given + val id = 123 + val defaultValue = "Test" + val deregisterCount = AtomicInteger(0) + + `when`(crowdinResources.getString(id)).thenReturn(defaultValue) + `when`(resources.getResourceEntryName(id)).thenReturn("test_string") + `when`(resources.getString(id)).thenReturn(defaultValue) + + repository.setWebSocketCallbacks( + onWatcherRegistered = { }, + onWatcherDeregistered = { deregisterCount.incrementAndGet() } + ) + + // When + repository.getStringState(id) + repository.registerWatcher(id) + repository.deRegisterWatcher(id) + + // Then + assertEquals(1, deregisterCount.get()) + assertTrue(repository.getActiveWatchers().isEmpty()) + } +} diff --git a/example/build.gradle b/example/build.gradle index d108945..7603396 100755 --- a/example/build.gradle +++ b/example/build.gradle @@ -1,8 +1,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' - id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" - + id 'org.jetbrains.kotlin.plugin.compose' version "2.2.0" } android { @@ -41,7 +40,8 @@ android { } buildFeatures { - compose true + buildConfig = true + compose = true } composeOptions { diff --git a/gradle.properties b/gradle.properties index 679fc1d..0f4ec4e 100755 --- a/gradle.properties +++ b/gradle.properties @@ -18,4 +18,17 @@ kotlin.code.style=official bintray.key="" bintray.user="" android.useAndroidX=true -android.defaults.buildfeatures.buildconfig=true \ No newline at end of file + +# Project version +crowdinVersion=1.16.0 +libraryVersionCode=53 + +# Publishing metadata +publishedGroupId=com.crowdin.platform +siteUrl=https://crowdin.com/ +gitUrl=https://github.com/crowdin/mobile-sdk-android +licenseName=The MIT License +licenseUrl=https://opensource.org/licenses/MIT +developerId=mykhailo-nester +developerName=Mykhailo Nester +developerEmail=nsmisha.dev@gmail.com \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..a69e213 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,65 @@ +[versions] +# https://github.com/JetBrains/kotlin +kotlin = "2.2.0" + +# Android Gradle Plugin +agp = "8.7.2" + +# https://github.com/Kotlin/binary-compatibility-validator +kotlin-binaryCompatibilityValidator = "0.16.3" + +# https://github.com/junit-team/junit4 +junit = "4.13.2" + +# https://github.com/gmazzo/gradle-buildconfig-plugin +buildconfig = "5.6.5" + +composeBom = "2025.02.00" + +[libraries] +kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } +kotlin-script-runtime = { group = "org.jetbrains.kotlin", name = "kotlin-script-runtime", version.ref = "kotlin" } +kotlin-test-junit5 = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit5", version.ref = "kotlin" } +kotlin-test-framework = { group = "org.jetbrains.kotlin", name = "kotlin-compiler-internal-test-framework", version.ref = "kotlin" } +kotlin-annotations-jvm = { group = "org.jetbrains.kotlin", name = "kotlin-annotations-jvm", version.ref = "kotlin" } +kotlin-compiler = { group = "org.jetbrains.kotlin", name = "kotlin-compiler", version.ref = "kotlin" } +kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } +kotlin-gradle-plugin-api = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin-api", version.ref = "kotlin" } + +junit = { module = "junit:junit", version.ref = "junit" } + +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-foundation-android = { group = "androidx.compose.foundation", name = "foundation-android" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "kotlin-binaryCompatibilityValidator"} +buildconfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildconfig"} + +gradle-java-test-fixtures = { id = "java-test-fixtures" } +gradle-idea = { id = "idea" } +gradle-plugin = { id = "java-gradle-plugin" } + +# Added plugin aliases for Android Gradle Plugin and Kotlin Android +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } + +# Kotlin Compose plugin +kotlinCompose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } + +[bundles] +androidx-ui = [ + "androidx-foundation-android", + "androidx-ui", + "androidx-ui-graphics", + "androidx-ui-tooling", + "androidx-ui-tooling-preview", + "androidx-ui-test-manifest", +] diff --git a/gradle/publishing.gradle.kts b/gradle/publishing.gradle.kts new file mode 100644 index 0000000..606d53c --- /dev/null +++ b/gradle/publishing.gradle.kts @@ -0,0 +1,47 @@ +/** + * Shared publishing configuration for all Crowdin modules. + * Apply this script in module build files with: apply(from = rootProject.file("gradle/publishing.gradle.kts")) + */ + +import org.gradle.api.publish.maven.MavenPublication + +// Helper object to configure POM metadata +object CrowdinPublishing { + fun configurePom( + publication: MavenPublication, + project: Project, + projectName: String, + projectDescription: String + ) { + publication.pom { + name.set(projectName) + description.set(projectDescription) + url.set(project.property("siteUrl") as String) + + licenses { + license { + name.set(project.property("licenseName") as String) + url.set(project.property("licenseUrl") as String) + } + } + + developers { + developer { + id.set(project.property("developerId") as String) + name.set(project.property("developerName") as String) + email.set(project.property("developerEmail") as String) + } + } + + scm { + val gitUrl = project.property("gitUrl") as String + connection.set("scm:git:$gitUrl.git") + developerConnection.set("scm:git:$gitUrl.git") + url.set(gitUrl) + } + } + } +} + +// Make the helper available +extra["CrowdinPublishing"] = CrowdinPublishing diff --git a/settings.gradle b/settings.gradle index ed2a5f1..eadadc7 100755 --- a/settings.gradle +++ b/settings.gradle @@ -19,5 +19,8 @@ dependencyResolutionManagement { rootProject.name = "Crowdin SDK" include ':example-info' -include ':crowdin-controls', ':crowdin' -include ':example', ':crowdin' +include ':crowdin-controls' +include ':example' +include ':crowdin-compiler-plugin' +include ':crowdin-gradle-plugin' +include ':crowdin' diff --git a/website/docs/advanced-features/jetpack-compose.mdx b/website/docs/advanced-features/jetpack-compose.mdx new file mode 100644 index 0000000..ed5f7e9 --- /dev/null +++ b/website/docs/advanced-features/jetpack-compose.mdx @@ -0,0 +1,103 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import CodeBlock from '@theme/CodeBlock'; + +import sampleComposeConfig from '!!raw-loader!../code-samples/jetpack-compose/config.kt'; +import sampleComposeUsage from '!!raw-loader!../code-samples/jetpack-compose/usage.kt'; + +# Jetpack Compose Realtime Preview + +The Crowdin Android SDK provides seamless integration with Jetpack Compose, enabling real-time updates for your application's UI strings without the need to restart the activity or the application. + +## Configuration + +To enable Jetpack Compose support, you need to configure the `CrowdinConfig` in your `Application` class. + + + {sampleComposeConfig} + + +:::info +The `withRealTimeComposeEnabled(true)` flag is required to activate the Compose-specific repositories and watchers. +::: + +:::warning Required Dependencies +**Both `withRealTimeUpdates()` and `withRealTimeComposeEnabled(true)` are required** for real-time Compose support to work properly: +- `withRealTimeUpdates()` - Establishes the WebSocket connection for receiving translation updates from Crowdin +- `withRealTimeComposeEnabled(true)` - Enables the Compose-specific integration layer to propagate updates to your UI + +If you enable `withRealTimeComposeEnabled(true)` without `withRealTimeUpdates()`, the SDK will throw a configuration error at initialization, as the Compose support needs the WebSocket connection to function. +::: + +:::info Android API Level Requirements +Real-time Compose support requires **Android API level 24 (Android 7.0) or higher**. This is because the implementation uses `ConcurrentHashMap.computeIfAbsent()` and `Map.putIfAbsent()` methods which are only available from API 24+. + +**On devices running API 21-23:** +- The SDK will automatically detect the incompatible API level +- Real-time Compose updates will be **gracefully disabled** +- A warning will be logged to help with debugging +- Your Compose UI will continue to work normally with static translations +- All other SDK features remain fully functional + +**Recommendation:** If you need to support API 21-23 devices, consider implementing a runtime check to conditionally enable real-time Compose support: + +```kotlin +val enableRealtimeCompose = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + +CrowdinConfig.Builder() + .withDistributionHash("your_hash") + .withRealTimeUpdates() + .withRealTimeComposeEnabled(enableRealtimeCompose) + .build() +``` +::: + +## Usage + +There are two ways to use Crowdin with Jetpack Compose: manually using the `crowdinString` composable or automatically using the Crowdin Gradle plugin. + +### Manual Usage + +You can use the `crowdinString` composable to retrieve localized strings that automatically update when the translation changes. + + + {sampleComposeUsage} + + +### Automatic Usage (Gradle Plugin) + +For a more seamless experience, you can use the Crowdin Gradle plugin. This plugin automatically transforms your `stringResource` calls into `crowdinString` calls at compile time, allowing you to use the standard Compose API while getting the benefits of Crowdin's real-time updates. + +#### Setup + +Add the Crowdin Gradle plugin to your module-level `build.gradle.kts` file. + +:::danger Critical Plugin Ordering +It is **mandatory** to apply the `com.crowdin.platform.gradle` plugin **before** the Compose (`org.jetbrains.kotlin.plugin.compose`) plugin. This ordering is required because the Crowdin plugin needs to transform the intermediate representation (IR) of your Compose code before the Compose compiler processes it. If the Crowdin plugin is applied after Compose, it will not be able to intercept and modify the IR, and real-time string updates will not work as intended. +::: + +```kotlin title="build.gradle.kts" +plugins { + // 1. Crowdin plugin MUST be applied first + id("com.crowdin.platform.gradle") + + // 2. Android and other plugins follow + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} +``` + +The plugin handles the rest automatically. You can continue writing standard Compose code: + +```kotlin +@Composable +fun Greeting() { + // This will be automatically transformed to use Crowdin's real-time updates + Text(text = stringResource(R.string.hello_world)) +} +``` + +### See also + +- [Real-Time Preview](real-time-preview.mdx) diff --git a/website/docs/advanced-features/real-time-preview.mdx b/website/docs/advanced-features/real-time-preview.mdx index c3febab..6ace9e2 100644 --- a/website/docs/advanced-features/real-time-preview.mdx +++ b/website/docs/advanced-features/real-time-preview.mdx @@ -43,6 +43,7 @@ import samplePreviewDisconnectJava from '!!raw-loader!../code-samples/real-time- :::tip Tips - To use the Real-Time Preview feature you still need to [wrap context](/setup#context-wrapping) for your activities. - To easily control the Real-Time Preview feature you could also use the [SDK Controls](/advanced-features/sdk-controls) UI widget. +- For Jetpack Compose applications, see [Jetpack Compose Realtime Support](jetpack-compose.mdx). ::: ### Config options diff --git a/website/docs/code-samples/jetpack-compose/config.kt b/website/docs/code-samples/jetpack-compose/config.kt new file mode 100644 index 0000000..4599748 --- /dev/null +++ b/website/docs/code-samples/jetpack-compose/config.kt @@ -0,0 +1,11 @@ +import com.crowdin.platform.Crowdin +import com.crowdin.platform.CrowdinConfig + +Crowdin.init( + application, + CrowdinConfig.Builder() + .withDistributionHash("your_distribution_hash") + .withRealTimeUpdates() + .withRealTimeComposeEnabled(true) // Enable Real-time Compose support + .build() +) diff --git a/website/docs/code-samples/jetpack-compose/usage.kt b/website/docs/code-samples/jetpack-compose/usage.kt new file mode 100644 index 0000000..a25ea94 --- /dev/null +++ b/website/docs/code-samples/jetpack-compose/usage.kt @@ -0,0 +1,10 @@ +import com.crowdin.platform.compose.crowdinString + +@Composable +fun WelcomeScreen() { + // Basic usage + Text(text = crowdinString(R.string.welcome_message)) + + // Usage with arguments + Text(text = crowdinString(R.string.welcome_user, "User")) +} diff --git a/website/sidebars.ts b/website/sidebars.ts index 79477de..cdd8be7 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -14,6 +14,7 @@ const sidebars: SidebarsConfig = { 'advanced-features/real-time-preview', 'advanced-features/screenshots', 'advanced-features/sdk-controls', + 'advanced-features/jetpack-compose', ] }, {