Skip to content

Commit f56016c

Browse files
committed
feat(i18n): ship Brazilian Portuguese and Spanish, with a language picker entry for each
The picker only offered English, so the Simplified Chinese already in the APK could only be reached from Android's per-app screen. It now lists all four, and a test fails if the picker, locales_config and the values directories disagree. A string with no translation falls back to English. Refs #44 Refs #7
1 parent 69def1d commit f56016c

10 files changed

Lines changed: 4152 additions & 16 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,7 @@ Run tests, lint, release metadata checks, and artifact builds locally before pub
617617

618618
## Translations
619619

620-
CallShield ships in English and Simplified Chinese. The Chinese text covers nearly every string, and the newest few fall back to English.
620+
CallShield ships in English, Simplified Chinese, Brazilian Portuguese and Spanish. A string that isn't translated yet falls back to English. The Portuguese and Spanish text hasn't had a native speaker's review, so corrections are welcome. Pick a language under Settings, or in Android's per-app language screen.
621621
More translations are welcome. See
622622
[docs/TRANSLATING.md](docs/TRANSLATING.md) for the resource layout, the priority
623623
order for partial translations, and `scripts/check_translations.py`, which
@@ -641,7 +641,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
641641
| URL Safety | Local spam-domain data, with optional PhishTank and OpenPhish |
642642
| Verification | Local Gradle, lint, and release-artifact checks |
643643
| Tests | 2134 JVM unit tests (JUnit) |
644-
| Strings | 1805 string resources and 52 plural groups (translation-ready) |
644+
| Strings | 1808 string resources and 52 plural groups (translation-ready) |
645645
| Accessibility | 100+ content descriptions, 48dp touch targets |
646646
| Min SDK | 29 (Android 10) |
647647
| Target SDK | 36 |

‎app/src/main/java/com/sysadmindoc/callshield/ui/AppLanguage.kt‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,19 @@ data class AppLanguageOption(
1414
object AppLanguage {
1515
const val SYSTEM_DEFAULT_TAG = ""
1616
const val ENGLISH_TAG = "en"
17+
const val CHINESE_SIMPLIFIED_TAG = "zh-CN"
18+
const val PORTUGUESE_BRAZIL_TAG = "pt-BR"
19+
const val SPANISH_TAG = "es"
1720
const val ACCENTED_PSEUDO_TAG = "en-XA"
1821
const val RTL_PSEUDO_TAG = "ar-XB"
1922

2023
fun options(includePseudoLocales: Boolean = BuildConfig.DEBUG): List<AppLanguageOption> =
2124
buildList {
2225
add(AppLanguageOption(SYSTEM_DEFAULT_TAG, R.string.settings_language_system_default))
2326
add(AppLanguageOption(ENGLISH_TAG, R.string.settings_language_english))
27+
add(AppLanguageOption(CHINESE_SIMPLIFIED_TAG, R.string.settings_language_chinese_simplified))
28+
add(AppLanguageOption(PORTUGUESE_BRAZIL_TAG, R.string.settings_language_portuguese_brazil))
29+
add(AppLanguageOption(SPANISH_TAG, R.string.settings_language_spanish))
2430
if (includePseudoLocales) {
2531
add(AppLanguageOption(ACCENTED_PSEUDO_TAG, R.string.settings_language_pseudo_accented))
2632
add(AppLanguageOption(RTL_PSEUDO_TAG, R.string.settings_language_pseudo_rtl))
@@ -40,11 +46,17 @@ object AppLanguage {
4046
AppCompatDelegate.setApplicationLocales(locales)
4147
}
4248

43-
internal fun normalizeLanguageTag(languageTag: String): String =
44-
when (languageTag.trim()) {
45-
ENGLISH_TAG -> ENGLISH_TAG
46-
ACCENTED_PSEUDO_TAG -> ACCENTED_PSEUDO_TAG
47-
RTL_PSEUDO_TAG -> RTL_PSEUDO_TAG
49+
internal fun normalizeLanguageTag(languageTag: String): String {
50+
val tag = languageTag.trim()
51+
return when {
52+
tag == ENGLISH_TAG -> ENGLISH_TAG
53+
tag == CHINESE_SIMPLIFIED_TAG -> CHINESE_SIMPLIFIED_TAG
54+
tag == PORTUGUESE_BRAZIL_TAG -> PORTUGUESE_BRAZIL_TAG
55+
// values-es has no region, so Android's own picker can hand back es-MX.
56+
tag == SPANISH_TAG || tag.startsWith("$SPANISH_TAG-") -> SPANISH_TAG
57+
tag == ACCENTED_PSEUDO_TAG -> ACCENTED_PSEUDO_TAG
58+
tag == RTL_PSEUDO_TAG -> RTL_PSEUDO_TAG
4859
else -> SYSTEM_DEFAULT_TAG
4960
}
61+
}
5062
}

‎app/src/main/res/values-es/strings.xml‎

Lines changed: 2009 additions & 0 deletions
Large diffs are not rendered by default.

‎app/src/main/res/values-pt-rBR/strings.xml‎

Lines changed: 2009 additions & 0 deletions
Large diffs are not rendered by default.

‎app/src/main/res/values/strings.xml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,10 @@
622622
<string name="settings_language">Language</string>
623623
<string name="settings_language_system_default">System default</string>
624624
<string name="settings_language_english">English</string>
625+
<!-- A language keeps its own name in the picker, whatever language the app is showing. -->
626+
<string name="settings_language_chinese_simplified" translatable="false">简体中文</string>
627+
<string name="settings_language_portuguese_brazil" translatable="false">Português (Brasil)</string>
628+
<string name="settings_language_spanish" translatable="false">Español</string>
625629
<string name="settings_language_pseudo_accented">Pseudolocale: accented</string>
626630
<string name="settings_language_pseudo_rtl">Pseudolocale: right-to-left</string>
627631
<string name="settings_language_dialog_title">Choose app language</string>

‎app/src/main/res/xml/locales_config.xml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@
22
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
33
<locale android:name="en" />
44
<locale android:name="zh-CN" />
5+
<locale android:name="pt-BR" />
6+
<locale android:name="es" />
57
</locale-config>

‎app/src/test/java/com/sysadmindoc/callshield/ui/AppLanguageTest.kt‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class AppLanguageTest {
1010
fun `release options contain only actual shipped locales`() {
1111
val options = AppLanguage.options(includePseudoLocales = false)
1212

13-
assertEquals(listOf("", "en"), options.map { it.languageTag })
13+
assertEquals(listOf("", "en", "zh-CN", "pt-BR", "es"), options.map { it.languageTag })
1414
}
1515

1616
@Test
@@ -19,16 +19,30 @@ class AppLanguageTest {
1919

2020
assertTrue(AppLanguage.ACCENTED_PSEUDO_TAG in tags)
2121
assertTrue(AppLanguage.RTL_PSEUDO_TAG in tags)
22-
assertFalse("es" in tags)
22+
assertFalse("de" in tags)
2323
}
2424

2525
@Test
2626
fun `unsupported tags fall back to system default`() {
27-
assertEquals("", AppLanguage.normalizeLanguageTag("es"))
27+
assertEquals("", AppLanguage.normalizeLanguageTag("de"))
2828
assertEquals("", AppLanguage.normalizeLanguageTag(" "))
2929
assertEquals("en", AppLanguage.normalizeLanguageTag(" en "))
3030
}
3131

32+
@Test
33+
fun `shipped translations keep their tags`() {
34+
assertEquals("zh-CN", AppLanguage.normalizeLanguageTag("zh-CN"))
35+
assertEquals("pt-BR", AppLanguage.normalizeLanguageTag("pt-BR"))
36+
assertEquals("es", AppLanguage.normalizeLanguageTag(" es "))
37+
}
38+
39+
@Test
40+
fun `Android's picker can hand back a regional Spanish and it still reads as Spanish`() {
41+
assertEquals("es", AppLanguage.normalizeLanguageTag("es-MX"))
42+
assertEquals("es", AppLanguage.normalizeLanguageTag("es-419"))
43+
assertEquals("", AppLanguage.normalizeLanguageTag("est"))
44+
}
45+
3246
@Test
3347
fun `language selection round trips through AppCompat`() {
3448
try {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.sysadmindoc.callshield.ui
2+
3+
import android.content.Context
4+
import android.content.res.XmlResourceParser
5+
import androidx.test.core.app.ApplicationProvider
6+
import com.sysadmindoc.callshield.R
7+
import org.junit.Assert.assertEquals
8+
import org.junit.Assert.assertNotEquals
9+
import org.junit.Test
10+
import org.junit.runner.RunWith
11+
import org.robolectric.RobolectricTestRunner
12+
import org.robolectric.annotation.Config
13+
14+
@RunWith(RobolectricTestRunner::class)
15+
@Config(sdk = [34])
16+
class ShippedLocalesTest {
17+
private val context: Context = ApplicationProvider.getApplicationContext()
18+
19+
private fun registeredLocales(): List<String> {
20+
val tags = mutableListOf<String>()
21+
context.resources.getXml(R.xml.locales_config).use { parser ->
22+
while (parser.next() != XmlResourceParser.END_DOCUMENT) {
23+
if (parser.eventType == XmlResourceParser.START_TAG && parser.name == "locale") {
24+
tags += parser.getAttributeValue("http://schemas.android.com/apk/res/android", "name")
25+
}
26+
}
27+
}
28+
return tags
29+
}
30+
31+
@Test
32+
fun `the in-app picker offers every locale Android's picker offers`() {
33+
val offered = AppLanguage.options(includePseudoLocales = false).map { it.languageTag } - AppLanguage.SYSTEM_DEFAULT_TAG
34+
35+
assertEquals(registeredLocales().sorted(), offered.sorted())
36+
}
37+
38+
@Test
39+
@Config(qualifiers = "pt-rBR")
40+
fun `Brazilian Portuguese is translated`() {
41+
assertEquals("Chamada recebida", context.getString(R.string.overlay_header_incoming_call))
42+
assertEquals(
43+
"CallShield",
44+
context.getString(R.string.app_name),
45+
)
46+
}
47+
48+
@Test
49+
@Config(qualifiers = "pt-rPT")
50+
fun `Portuguese from Portugal gets the Brazilian text rather than English`() {
51+
assertEquals("Chamada recebida", context.getString(R.string.overlay_header_incoming_call))
52+
}
53+
54+
@Test
55+
@Config(qualifiers = "es")
56+
fun `Spanish is translated`() {
57+
assertEquals("Llamada entrante", context.getString(R.string.overlay_header_incoming_call))
58+
}
59+
60+
@Test
61+
@Config(qualifiers = "es-rMX")
62+
fun `a regional Spanish phone gets the Spanish text`() {
63+
assertEquals("Llamada entrante", context.getString(R.string.overlay_header_incoming_call))
64+
assertNotEquals("Incoming call", context.getString(R.string.overlay_header_incoming_call))
65+
}
66+
67+
@Test
68+
@Config(qualifiers = "es")
69+
fun `every language keeps its own name in the picker`() {
70+
assertEquals("简体中文", context.getString(R.string.settings_language_chinese_simplified))
71+
assertEquals("Português (Brasil)", context.getString(R.string.settings_language_portuguese_brazil))
72+
assertEquals("Español", context.getString(R.string.settings_language_spanish))
73+
}
74+
}

‎docs/TRANSLATING.md‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
# Translating CallShield
22

3-
CallShield ships English only today. Translations are welcome, and partial ones
4-
are genuinely useful. Android falls back to English per string, so a translation
5-
that covers the screens people actually look at is better than none.
3+
CallShield ships in English, Simplified Chinese, Brazilian Portuguese and
4+
Spanish. New languages are welcome, and partial ones help. Android falls back to
5+
English per string, so a translation that covers the screens people actually
6+
look at is better than none.
7+
8+
The Portuguese and Spanish text hasn't had a native speaker's review yet. If
9+
something reads wrong, a PR that fixes a few lines is as useful as a whole new
10+
language.
611

712
The general translation thread is
813
[issue #7](https://github.com/SysAdminDoc/CallShield/issues/7). Comment there to
9-
claim a language so two people don't translate the same 1,103 strings.
14+
claim a language so two people don't translate the same 1,800 strings.
1015

1116
## What to translate
1217

13-
The source of truth is `app/src/main/res/values/strings.xml` (1,103 strings and
14-
30 `<plurals>`). Create `app/src/main/res/values-<locale>/` and put your
18+
The source of truth is `app/src/main/res/values/strings.xml` (about 1,800 strings
19+
and 50 `<plurals>`). Create `app/src/main/res/values-<locale>/` and put your
1520
translated `<string>` and `<plurals>` elements there. Either layout works,
1621
everything in one `strings.xml` or split into `strings.xml` + `plurals.xml`.
1722
Resource filenames carry no meaning to the Android build.
@@ -23,6 +28,7 @@ Locale directory names use Android's qualifier form, not BCP-47:
2328
| Simplified Chinese | `values-zh-rCN` |
2429
| German | `values-de` |
2530
| Brazilian Portuguese | `values-pt-rBR` |
31+
| Spanish | `values-es` |
2632

2733
**Priority order**, if you are not translating everything:
2834

@@ -47,6 +53,10 @@ populates the per-app language picker in Android's system settings. Skip it and
4753
your translation only appears for users whose entire device is set to that
4854
language. It is the single easiest thing to forget.
4955

56+
Then add it to the app's own picker in `ui/AppLanguage.kt`, with its name in its
57+
own language as a `translatable="false"` string. `ShippedLocalesTest` fails if
58+
the two lists disagree.
59+
5060
## Check your work
5161

5262
```bash

‎scripts/translation_floors.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
{
22
"description": "Minimum translated-string coverage per locale, in percent. check_translations.py fails when a shipped locale drops below its floor. --update-floors only raises a floor. Lowering one is a hand edit, made on purpose and explained in the commit message.",
33
"floors": {
4+
"values-es": 98.5,
5+
"values-pt-rBR": 98.5,
46
"values-zh-rCN": 98.5
57
}
68
}

0 commit comments

Comments
 (0)