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)
+}