From 3031d6886368056c203f7afb229315d89cd2963e Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 31 Dec 2025 13:51:07 -0500 Subject: [PATCH] Move the pullTranslations script into the codebase as kotlin. --- app/build.gradle.kts | 1 - .../main/java/translations-kotlin.gradle.kts | 56 ------ .../src/main/java/translations.gradle.kts | 187 +++++++++++++++++- .../org/signal/buildtools/SmartlingClient.kt | 55 +++++- 4 files changed, 232 insertions(+), 67 deletions(-) delete mode 100644 build-logic/plugins/src/main/java/translations-kotlin.gradle.kts diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 91a591f2c0..3177d02270 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,7 +17,6 @@ plugins { id("kotlin-parcelize") id("com.squareup.wire") id("translations") - id("translations-kotlin") id("licenses") } diff --git a/build-logic/plugins/src/main/java/translations-kotlin.gradle.kts b/build-logic/plugins/src/main/java/translations-kotlin.gradle.kts deleted file mode 100644 index d9d09cb4aa..0000000000 --- a/build-logic/plugins/src/main/java/translations-kotlin.gradle.kts +++ /dev/null @@ -1,56 +0,0 @@ -import org.signal.buildtools.SmartlingClient -import java.io.File -import java.util.Properties - - -/** - * Kotlin-based translation tasks. - * - * Requires the following properties in local.properties: - * smartling.userIdentifier - Smartling API user identifier - * smartling.userSecret - Smartling API user secret - * smartling.projectId - (optional) Smartling project ID, defaults to "3e5533321" - */ - -tasks.register("pushTranslations") { - group = "Translations" - description = "Pushes the main strings.xml file to Smartling for translation" - - doLast { - val localPropertiesFile = File(rootDir, "local.properties") - if (!localPropertiesFile.exists()) { - throw GradleException("local.properties not found at ${localPropertiesFile.absolutePath}") - } - - val localProperties = Properties().apply { - localPropertiesFile.inputStream().use { load(it) } - } - - val userIdentifier = localProperties.requireProperty("smartling.userIdentifier") - val userSecret = localProperties.requireProperty("smartling.userSecret") - val projectId = localProperties.requireProperty("smartling.projectId") - - val stringsFile = File(rootDir, "app/src/main/res/values/strings.xml") - if (!stringsFile.exists()) { - throw GradleException("strings.xml not found at ${stringsFile.absolutePath}") - } - - println("Using Signal-Android root directory of $rootDir") - - val client = SmartlingClient(userIdentifier, userSecret, projectId) - - println("Fetching auth...") - val authToken = client.authenticate() - println("> Done") - println() - - println("Uploading file...") - val response = client.uploadFile(authToken, stringsFile, "strings.xml") - println(response) - println("> Done") - } -} - -private fun Properties.requireProperty(name: String): String { - return getProperty(name) ?: throw GradleException("$name not found in local.properties") -} diff --git a/build-logic/plugins/src/main/java/translations.gradle.kts b/build-logic/plugins/src/main/java/translations.gradle.kts index e63ef91498..b65e0d2e61 100644 --- a/build-logic/plugins/src/main/java/translations.gradle.kts +++ b/build-logic/plugins/src/main/java/translations.gradle.kts @@ -1,12 +1,104 @@ import groovy.util.Node import groovy.xml.XmlParser +import org.signal.buildtools.SmartlingClient import org.signal.buildtools.StaticIpResolver import java.io.File +import java.util.Properties +import java.util.concurrent.Executors +import java.util.concurrent.Future /** * Tasks for managing translations and static files. + * + * Smartling tasks require the following properties in local.properties: + * smartling.userIdentifier - Smartling API user identifier + * smartling.userSecret - Smartling API user secret + * smartling.projectId - Smartling project ID */ +// ===================== +// Smartling Tasks +// ===================== + +tasks.register("pushTranslations") { + group = "Translations" + description = "Pushes the main strings.xml file to Smartling for translation" + + doLast { + val client = createSmartlingClient() + + val stringsFile = File(rootDir, "app/src/main/res/values/strings.xml") + if (!stringsFile.exists()) { + throw GradleException("strings.xml not found at ${stringsFile.absolutePath}") + } + + println("Using Signal-Android root directory of $rootDir") + + println("Fetching auth...") + val authToken = client.authenticate() + println("> Done") + println() + + println("Uploading file...") + val response = client.uploadFile(authToken, stringsFile, "strings.xml") + println(response) + println("> Done") + } +} + +tasks.register("pullTranslations") { + group = "Translations" + description = "Pulls translated strings.xml files from Smartling for all locales" + + doLast { + val client = createSmartlingClient() + val resDir = File(rootDir, "app/src/main/res") + + println("Using Signal-Android root directory of $rootDir") + + println("Fetching auth...") + val authToken = client.authenticate() + println("> Done") + println() + + println("Fetching locales...") + val locales = client.getLocales(authToken, "strings.xml") + println("Found ${locales.size} locales") + println("> Done") + println() + + println("Fetching files...") + val executor = Executors.newFixedThreadPool(35) + val futures = mutableListOf>>() + + for (locale in locales) { + if (locale in localeBlocklist) { + continue + } + + futures += executor.submit> { + val content = client.downloadFile(authToken, "strings.xml", locale) + println("Successfully pulled file for locale $locale") + locale to content + } + } + + val results = futures.map { it.get() } + executor.shutdown() + println("> Done") + println() + + println("Writing files...") + for ((locale, content) in results) { + val androidLocale = localeMap[locale] ?: locale + val localeDir = File(resDir, "values-$androidLocale") + localeDir.mkdirs() + File(localeDir, "strings.xml").writeText(content) + } + println("> Done") + } +} + tasks.register("replaceEllipsis") { group = "Static Files" description = "Process strings for ellipsis characters." @@ -110,16 +202,17 @@ tasks.register("resolveStaticIps") { description = "Fetches static IPs for core hosts and writes them to static-ips.gradle" doLast { val staticIpResolver = StaticIpResolver() + val tripleQuote = "\"\"\"" val content = """ - rootProject.extra["service_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("chat.signal.org")}${"\"\"\""} - rootProject.extra["storage_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("storage.signal.org")}${"\"\"\""} - rootProject.extra["cdn_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("cdn.signal.org")}${"\"\"\""} - rootProject.extra["cdn2_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("cdn2.signal.org")}${"\"\"\""} - rootProject.extra["cdn3_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("cdn3.signal.org")}${"\"\"\""} - rootProject.extra["sfu_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("sfu.voip.signal.org")}${"\"\"\""} - rootProject.extra["content_proxy_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("contentproxy.signal.org")}${"\"\"\""} - rootProject.extra["svr2_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("svr2.signal.org")}${"\"\"\""} - rootProject.extra["cdsi_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("cdsi.signal.org")}${"\"\"\""} + rootProject.extra["service_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("chat.signal.org")}$tripleQuote + rootProject.extra["storage_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("storage.signal.org")}$tripleQuote + rootProject.extra["cdn_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("cdn.signal.org")}$tripleQuote + rootProject.extra["cdn2_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("cdn2.signal.org")}$tripleQuote + rootProject.extra["cdn3_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("cdn3.signal.org")}$tripleQuote + rootProject.extra["sfu_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("sfu.voip.signal.org")}$tripleQuote + rootProject.extra["content_proxy_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("contentproxy.signal.org")}$tripleQuote + rootProject.extra["svr2_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("svr2.signal.org")}$tripleQuote + rootProject.extra["cdsi_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("cdsi.signal.org")}$tripleQuote """.trimIndent() + "\n" File(projectDir, "static-ips.gradle.kts").writeText(content) } @@ -137,3 +230,79 @@ private fun allStringsResourceFiles(action: (File) -> Unit) { .filter { it.isFile && it.name == "strings.xml" } .forEach(action) } + +private fun createSmartlingClient(): SmartlingClient { + val localPropertiesFile = File(rootDir, "local.properties") + if (!localPropertiesFile.exists()) { + throw GradleException("local.properties not found at ${localPropertiesFile.absolutePath}") + } + + val localProperties = Properties().apply { + localPropertiesFile.inputStream().use { load(it) } + } + + val userIdentifier = localProperties.requireProperty("smartling.userIdentifier") + val userSecret = localProperties.requireProperty("smartling.userSecret") + val projectId = localProperties.requireProperty("smartling.projectId") + + return SmartlingClient(userIdentifier, userSecret, projectId) +} + +private fun Properties.requireProperty(name: String): String { + return getProperty(name) ?: throw GradleException("$name not found in local.properties") +} + +/** + * A mapping of smartling-locale => Android locale. + * Only needed when they differ. + */ +private val localeMap = mapOf( + "af-ZA" to "af", + "az-AZ" to "az", + "be-BY" to "be", + "bg-BG" to "bg", + "bn-BD" to "bn", + "bs-BA" to "bs", + "et-EE" to "et", + "fa-IR" to "fa", + "ga-IE" to "ga", + "gl-ES" to "gl", + "gu-IN" to "gu", + "he" to "iw", + "hi-IN" to "hi", + "hr-HR" to "hr", + "id" to "in", + "ka-GE" to "ka", + "kk-KZ" to "kk", + "km-KH" to "km", + "kn-IN" to "kn", + "ky-KG" to "ky", + "lt-LT" to "lt", + "lv-LV" to "lv", + "mk-MK" to "mk", + "ml-IN" to "ml", + "mr-IN" to "mr", + "pa-IN" to "pa", + "pt-BR" to "pt-rBR", + "pt-PT" to "pt", + "ro-RO" to "ro", + "sk-SK" to "sk", + "sl-SI" to "sl", + "sq-AL" to "sq", + "sr-RS" to "sr-rRS", + "sr-YR" to "sr", + "ta-IN" to "ta", + "te-IN" to "te", + "tl-PH" to "tl", + "uk-UA" to "uk", + "zh-CN" to "zh-rCN", + "zh-HK" to "zh-rHK", + "zh-TW" to "zh-rTW", + "zh-YU" to "yue" +) + +/** + * Locales that should not be saved, even if present remotely. + * Typically for unfinished translations not ready to be public. + */ +val localeBlocklist = emptySet() diff --git a/build-logic/tools/src/main/java/org/signal/buildtools/SmartlingClient.kt b/build-logic/tools/src/main/java/org/signal/buildtools/SmartlingClient.kt index e1f778b2c6..5bb4a4632d 100644 --- a/build-logic/tools/src/main/java/org/signal/buildtools/SmartlingClient.kt +++ b/build-logic/tools/src/main/java/org/signal/buildtools/SmartlingClient.kt @@ -9,6 +9,7 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import java.io.File +import java.util.concurrent.TimeUnit /** * Client for interacting with the Smartling translation API. @@ -19,7 +20,12 @@ class SmartlingClient( private val projectId: String ) { - private val client = OkHttpClient() + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + private val jsonParser = JsonSlurper() /** @@ -82,5 +88,52 @@ class SmartlingClient( return responseBody } + /** + * Gets the list of locales that have translations for a given file. + */ + @Suppress("UNCHECKED_CAST") + fun getLocales(authToken: String, fileUri: String): List { + val request = Request.Builder() + .url("https://api.smartling.com/files-api/v2/projects/$projectId/file/status?fileUri=$fileUri") + .header("Authorization", "Bearer $authToken") + .get() + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body.string() + + if (!response.isSuccessful) { + throw SmartlingException("Failed to get locales with code ${response.code}: $responseBody") + } + + val json = jsonParser.parseText(responseBody) as Map + val responseObj = json["response"] as? Map + val data = responseObj?.get("data") as? Map + val items = data?.get("items") as? List> + + return items?.mapNotNull { it["localeId"] as? String } + ?: throw SmartlingException("Failed to extract locales from response: $responseBody") + } + + /** + * Downloads the translated file for a specific locale. + */ + fun downloadFile(authToken: String, fileUri: String, locale: String): String { + val request = Request.Builder() + .url("https://api.smartling.com/files-api/v2/projects/$projectId/locales/$locale/file?fileUri=$fileUri") + .header("Authorization", "Bearer $authToken") + .get() + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body.string() + + if (!response.isSuccessful) { + throw SmartlingException("Failed to download file for locale $locale with code ${response.code}: $responseBody") + } + + return responseBody + } + class SmartlingException(message: String) : RuntimeException(message) }