From fe1755f250c77e7ed809d99df9912504965e3fb9 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 31 Dec 2025 12:43:15 -0500 Subject: [PATCH] Move the pushTranslations script into the codebase as kotlin. --- app/build.gradle.kts | 1 + app/src/main/res/values/strings.xml | 2 +- app/static-ips.gradle.kts | 6 +- .../main/java/translations-kotlin.gradle.kts | 56 +++++++ .../plugins/src/main/java/translations.gradle | 128 ---------------- .../src/main/java/translations.gradle.kts | 139 ++++++++++++++++++ build-logic/tools/build.gradle.kts | 2 + .../org/signal/buildtools/SmartlingClient.kt | 86 +++++++++++ 8 files changed, 288 insertions(+), 132 deletions(-) create mode 100644 build-logic/plugins/src/main/java/translations-kotlin.gradle.kts delete mode 100644 build-logic/plugins/src/main/java/translations.gradle create mode 100644 build-logic/plugins/src/main/java/translations.gradle.kts create mode 100644 build-logic/tools/src/main/java/org/signal/buildtools/SmartlingClient.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3177d02270..91a591f2c0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,6 +17,7 @@ plugins { id("kotlin-parcelize") id("com.squareup.wire") id("translations") + id("translations-kotlin") id("licenses") } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb52b15d34..514e32b667 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4189,7 +4189,7 @@ Normal Compact - + The app will restart to apply the new language setting. diff --git a/app/static-ips.gradle.kts b/app/static-ips.gradle.kts index ae1aa3cfd6..a562b1c4a2 100644 --- a/app/static-ips.gradle.kts +++ b/app/static-ips.gradle.kts @@ -1,9 +1,9 @@ rootProject.extra["service_ips"] = """new String[]{"13.248.212.111","76.223.92.165"}""" -rootProject.extra["storage_ips"] = """new String[]{"142.250.69.147"}""" -rootProject.extra["cdn_ips"] = """new String[]{"13.225.196.60","13.225.196.76","13.225.196.77","13.225.196.9"}""" +rootProject.extra["storage_ips"] = """new String[]{"142.250.176.211"}""" +rootProject.extra["cdn_ips"] = """new String[]{"18.161.21.122","18.161.21.4","18.161.21.66","18.161.21.70"}""" rootProject.extra["cdn2_ips"] = """new String[]{"104.18.10.47","104.18.11.47"}""" rootProject.extra["cdn3_ips"] = """new String[]{"104.18.10.47","104.18.11.47"}""" rootProject.extra["sfu_ips"] = """new String[]{"34.117.136.13"}""" rootProject.extra["content_proxy_ips"] = """new String[]{"107.178.250.75"}""" -rootProject.extra["svr2_ips"] = """new String[]{"20.104.52.125"}""" +rootProject.extra["svr2_ips"] = """new String[]{"20.119.62.85"}""" rootProject.extra["cdsi_ips"] = """new String[]{"40.122.45.194"}""" diff --git a/build-logic/plugins/src/main/java/translations-kotlin.gradle.kts b/build-logic/plugins/src/main/java/translations-kotlin.gradle.kts new file mode 100644 index 0000000000..d9d09cb4aa --- /dev/null +++ b/build-logic/plugins/src/main/java/translations-kotlin.gradle.kts @@ -0,0 +1,56 @@ +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 b/build-logic/plugins/src/main/java/translations.gradle deleted file mode 100644 index 06a3658ebb..0000000000 --- a/build-logic/plugins/src/main/java/translations.gradle +++ /dev/null @@ -1,128 +0,0 @@ -import groovy.io.FileType -import groovy.transform.stc.ClosureParams -import groovy.transform.stc.SimpleType -import org.signal.buildtools.StaticIpResolver - -def allStringsResourceFiles(@ClosureParams(value = SimpleType.class, options = ['java.io.File']) Closure c) { - file('src/main/res').eachFileRecurse(FileType.FILES) { f -> - if (f.name == 'strings.xml') { - c(f) - } - } -} - -task replaceEllipsis { - group 'Static Files' - description 'Process strings for ellipsis characters.' - doLast { - allStringsResourceFiles { f -> - def before = f.text - def after = f.text.replace('...', '…') - if (before != after) { - f.text = after - logger.info("$f.parentFile.name/$f.name...updated") - } - } - } -} - -task cleanApostropheErrors { - group 'Static Files' - description 'Fix transifex apostrophe string errors.' - doLast { - allStringsResourceFiles { f -> - def before = f.text - def after = before.replaceAll(/([^\\=08])(')/, '$1\\\\\'') - if (before != after) { - f.text = after - logger.info("$f.parentFile.name/$f.name...updated") - } - } - } -} - -task excludeNonTranslatables { - group 'Static Files' - description 'Remove strings that are marked "translatable"="false" or are ExtraTranslations.' - doLast { - def englishFile = file('src/main/res/values/strings.xml') - - def english = new XmlParser().parse(englishFile) - def nonTranslatable = english - .findAll { it['@translatable'] == 'false' } - .collect { it['@name'] } - .toSet() - def all = english.collect { it['@name'] }.toSet() - def translatable = all - nonTranslatable - def inMultiline = false - def endBlockName = "" - - allStringsResourceFiles { f -> - if (f != englishFile) { - def newLines = f.readLines() - .collect { line -> - if (!inMultiline) { - def singleLineMatcher = line =~ /name="([^"]*)".*(<\/|\/>)/ - if (singleLineMatcher.find()) { - def name = singleLineMatcher.group(1) - if (!line.contains('excludeNonTranslatables') && !translatable.contains(name)) { - return " " - } - } else { - def multilineStartMatcher = line =~ /<(.*) .?name="([^"]*)".*/ - if (multilineStartMatcher.find()) { - endBlockName = multilineStartMatcher.group(1) - def name = multilineStartMatcher.group(2) - if (!line.contains('excludeNonTranslatables') && !translatable.contains(name)) { - inMultiline = true; - return " " - } - } - - return line - } - - f.write(newLines.join("\n") + "\n") - } - } - } -} - -task postTranslateQa { - group 'Static Files' - description 'Runs QA to check validity of updated strings, and ensure presence of any new languages in internal lists.' - dependsOn ':qa' -} - -task resolveStaticIps { - group 'Static Files' - description 'Fetches static IPs for core hosts and writes them to static-ips.gradle' - doLast { - def staticIpResolver = new StaticIpResolver() - new File(projectDir, "static-ips.gradle.kts").text = """ - 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")}\"\"\" - """.stripIndent().trim() + "\n" - } -} - -task updateStaticFilesAndQa { - group 'Static Files' - description 'Runs tasks to update static files. This includes translations, static IPs, and licenses. Runs QA afterwards to verify all went well. Intended to be run before cutting a release.' - dependsOn replaceEllipsis, cleanApostropheErrors, excludeNonTranslatables, resolveStaticIps, postTranslateQa -} diff --git a/build-logic/plugins/src/main/java/translations.gradle.kts b/build-logic/plugins/src/main/java/translations.gradle.kts new file mode 100644 index 0000000000..e63ef91498 --- /dev/null +++ b/build-logic/plugins/src/main/java/translations.gradle.kts @@ -0,0 +1,139 @@ +import groovy.util.Node +import groovy.xml.XmlParser +import org.signal.buildtools.StaticIpResolver +import java.io.File + +/** + * Tasks for managing translations and static files. + */ + +tasks.register("replaceEllipsis") { + group = "Static Files" + description = "Process strings for ellipsis characters." + doLast { + allStringsResourceFiles { f -> + val before = f.readText() + val after = before.replace("...", "…") + if (before != after) { + f.writeText(after) + logger.info("${f.parentFile.name}/${f.name}...updated") + } + } + } +} + +tasks.register("cleanApostropheErrors") { + group = "Static Files" + description = "Fix smartling apostrophe string errors." + doLast { + val pattern = Regex("""([^\\=08])(')""") + allStringsResourceFiles { f -> + val before = f.readText() + val after = pattern.replace(before) { match -> + "${match.groupValues[1]}\\'" + } + if (before != after) { + f.writeText(after) + logger.info("${f.parentFile.name}/${f.name}...updated") + } + } + } +} + +tasks.register("excludeNonTranslatables") { + group = "Static Files" + description = "Remove strings that are marked \"translatable\"=\"false\" or are ExtraTranslations." + doLast { + val englishFile = file("src/main/res/values/strings.xml") + + val english = XmlParser().parse(englishFile) + val nonTranslatable = english.children() + .filterIsInstance() + .filter { it.attribute("translatable") == "false" } + .mapNotNull { it.attribute("name") as? String } + .toSet() + val all = english.children() + .filterIsInstance() + .mapNotNull { it.attribute("name") as? String } + .toSet() + val translatable = all - nonTranslatable + + allStringsResourceFiles { f -> + if (f != englishFile) { + var inMultiline = false + var endBlockName = "" + + val newLines = f.readLines().map { line -> + if (!inMultiline) { + val singleLineMatcher = Regex("""name="([^"]*)".*(<\/|\/>)""").find(line) + if (singleLineMatcher != null) { + val name = singleLineMatcher.groupValues[1] + if (!line.contains("excludeNonTranslatables") && name !in translatable) { + return@map " " + } + } else { + val multilineStartMatcher = Regex("""<(.*) .?name="([^"]*)".*""").find(line) + if (multilineStartMatcher != null) { + endBlockName = multilineStartMatcher.groupValues[1] + val name = multilineStartMatcher.groupValues[2] + if (!line.contains("excludeNonTranslatables") && name !in translatable) { + inMultiline = true + return@map " " + } + } + + line + } + + f.writeText(newLines.joinToString("\n") + "\n") + } + } + } +} + +tasks.register("postTranslateQa") { + group = "Static Files" + description = "Runs QA to check validity of updated strings, and ensure presence of any new languages in internal lists." + dependsOn(":qa") +} + +tasks.register("resolveStaticIps") { + group = "Static Files" + description = "Fetches static IPs for core hosts and writes them to static-ips.gradle" + doLast { + val staticIpResolver = StaticIpResolver() + 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")}${"\"\"\""} + """.trimIndent() + "\n" + File(projectDir, "static-ips.gradle.kts").writeText(content) + } +} + +tasks.register("updateStaticFilesAndQa") { + group = "Static Files" + description = "Runs tasks to update static files. This includes translations, static IPs, and licenses. Runs QA afterwards to verify all went well. Intended to be run before cutting a release." + dependsOn("replaceEllipsis", "cleanApostropheErrors", "excludeNonTranslatables", "resolveStaticIps", "postTranslateQa") +} + +private fun allStringsResourceFiles(action: (File) -> Unit) { + val resDir = file("src/main/res") + resDir.walkTopDown() + .filter { it.isFile && it.name == "strings.xml" } + .forEach(action) +} diff --git a/build-logic/tools/build.gradle.kts b/build-logic/tools/build.gradle.kts index 238080c66c..745b6aff44 100644 --- a/build-logic/tools/build.gradle.kts +++ b/build-logic/tools/build.gradle.kts @@ -25,6 +25,8 @@ dependencies { implementation(gradleApi()) implementation(libs.dnsjava) + implementation(libs.square.okhttp3) + testImplementation(testLibs.junit.junit) testImplementation(testLibs.mockk) } 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 new file mode 100644 index 0000000000..e1f778b2c6 --- /dev/null +++ b/build-logic/tools/src/main/java/org/signal/buildtools/SmartlingClient.kt @@ -0,0 +1,86 @@ +package org.signal.buildtools + +import groovy.json.JsonBuilder +import groovy.json.JsonSlurper +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File + +/** + * Client for interacting with the Smartling translation API. + */ +class SmartlingClient( + private val userIdentifier: String, + private val userSecret: String, + private val projectId: String +) { + + private val client = OkHttpClient() + private val jsonParser = JsonSlurper() + + /** + * Authenticates with Smartling and returns an access token. + */ + @Suppress("UNCHECKED_CAST") + fun authenticate(): String { + val jsonBody = JsonBuilder( + mapOf( + "userIdentifier" to userIdentifier, + "userSecret" to userSecret + ) + ).toString() + + val request = Request.Builder() + .url("https://api.smartling.com/auth-api/v2/authenticate") + .post(jsonBody.toRequestBody("application/json".toMediaType())) + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body.string() + + if (!response.isSuccessful) { + throw SmartlingException("Authentication failed 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 accessToken = data?.get("accessToken") as? String + + return accessToken + ?: throw SmartlingException("Failed to extract access token from response: $responseBody") + } + + /** + * Uploads a file to Smartling for translation. + */ + fun uploadFile(authToken: String, file: File, fileUri: String): String { + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", file.name, file.asRequestBody("application/xml".toMediaType())) + .addFormDataPart("fileUri", fileUri) + .addFormDataPart("fileType", "android") + .build() + + val request = Request.Builder() + .url("https://api.smartling.com/files-api/v2/projects/$projectId/file") + .header("Authorization", "Bearer $authToken") + .post(requestBody) + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body.string() + + if (!response.isSuccessful) { + throw SmartlingException("Upload failed with code ${response.code}: $responseBody") + } + + return responseBody + } + + class SmartlingException(message: String) : RuntimeException(message) +}