Move the pushTranslations script into the codebase as kotlin.

This commit is contained in:
Greyson Parrelli
2025-12-31 12:43:15 -05:00
committed by jeffrey-signal
parent e162eb27c7
commit fe1755f250
8 changed files with 288 additions and 132 deletions

View File

@@ -17,6 +17,7 @@ plugins {
id("kotlin-parcelize")
id("com.squareup.wire")
id("translations")
id("translations-kotlin")
id("licenses")
}

View File

@@ -4189,7 +4189,7 @@
<string name="preferences_normal">Normal</string>
<!-- Preference summary for compact navigation bar size -->
<string name="preferences_compact">Compact</string>
<!-- Dialog message body explaining that we have to restart the app in order to apply the user's new language setting. -->
<!-- Dialog message body explaining that we have to restart the app in order to apply the user\'s new language setting. -->
<string name="preferences_language_change_confirmation_message">The app will restart to apply the new language setting.</string>

View File

@@ -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"}"""

View File

@@ -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")
}

View File

@@ -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 " <!-- Removed by excludeNonTranslatables ${line.trim()} -->"
}
} 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 " <!-- Removed by excludeNonTranslatables ${line.trim()}"
}
}
}
} else {
def multilineEndMatcher = line =~ /<\/${endBlockName}/
if (multilineEndMatcher.find()) {
inMultiline = false
return "${line} -->"
}
}
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
}

View File

@@ -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<Node>()
.filter { it.attribute("translatable") == "false" }
.mapNotNull { it.attribute("name") as? String }
.toSet()
val all = english.children()
.filterIsInstance<Node>()
.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 " <!-- Removed by excludeNonTranslatables ${line.trim()} -->"
}
} 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 " <!-- Removed by excludeNonTranslatables ${line.trim()}"
}
}
}
} else {
val multilineEndMatcher = Regex("""</$endBlockName""").find(line)
if (multilineEndMatcher != null) {
inMultiline = false
return@map "$line -->"
}
}
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)
}

View File

@@ -25,6 +25,8 @@ dependencies {
implementation(gradleApi())
implementation(libs.dnsjava)
implementation(libs.square.okhttp3)
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.mockk)
}

View File

@@ -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<String, Any>
val responseObj = json["response"] as? Map<String, Any>
val data = responseObj?.get("data") as? Map<String, Any>
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)
}