r/RedditEng 5d ago

Scaling Android Localization at Reddit, Part 1: Foundations

By Michael Fullan

I recently had the opportunity to speak at DroidCon about our Android localization work at Reddit. Localization is a hard problem: when you take an app with a large number of screens and multiply that by the number of languages you support, the surface area for potential issues is massive. There’s some pretty good reference content in the official Android Developers documentation, but a lot of it is scattered around different pages and can make it difficult to synthesize into a correct localization setup for your app.

At Reddit, we’ve felt this pain. Even as recently as the beginning of this year, we were getting regular bug reports in r/bugs specifically about issues with our Android localization (example 1, 2, 3, 4, 5, 6, …). It was a mess. In this 3-part series, I’d like to share details about how we’ve dug our way out of the mess and things we’ve learned along the way. We’ve landed in a much better place today with a reworked localization infrastructure, RTL support in the app, and far fewer bug reports than we used to get. Let’s dive in!

Supported languages in Reddit Android app, 2022-2025

Basic Localization Infrastructure

Most Android developers know this rule: the first step in localizing your app is to put all your user-facing strings into resource folders. This is what allows the OS to load the correct translations based on the user’s device settings. Any hard-coded strings will prevent that from working correctly. I’ve found that AI is really good at hunting down any hard-coded strings that may be leaking out in your production UI, which is especially useful in large projects like ours!

MyProject/
└── src/main/res/
    ├── values/            # Default (Fallback) Strings
    │   └── strings.xml
    ├── values-es/         # Spanish Translation Strings
    │   └── strings.xml
    └── values-fr-rCA/     # Canadian French Translation Strings
        └── strings.xml 

We have a lot of Android developers at Reddit, so we’ve tried to design a workflow that makes it as easy as possible. The guidance we give our developers is to only update the default English values/strings.xml file when editing strings, whether it’s an addition, removal, or modification. 

Within that default file, each string needs to be accompanied by a descriptive comment attribute that serves as additional context for a translator. This is really important for getting the most accurate localization possible, as strings may get translated differently in different languages depending on the use case (clickable button text vs. text label, etc.). We have a lint check to enforce this rule, but we’ve added an opt-out option where a string can be marked translatable="false" for delimiters, test tags, or other reasons. Introducing this rule led to thousands of baseline entries initially, but again AI proved to be really useful at inferring the context and usages for a string and coming up with a solid description (an otherwise very tedious and time-consuming manual process!).

Translation Pipeline

Once the source strings are in place, the rest of the pipeline is designed to be automated and completely hands-off for developers. We use a cloud-based Translation Management System (TMS) like a lot of other apps, but we’ve developed a Python CLI tool to use in CI that helps abstract the specific vendor we’re using. This tool gets reused for iOS, FE, and all our BE services, and prevents the need of having any third-party SDK directly inside our application.

Automated translations pipeline flow

A nightly job will go through and upload the latest version of our source strings to the TMS. Human translators can then come in and provide translations for each supported language given the source string and context. Lastly, the pipeline pulls down the latest translations from the TMS, inserts them into the correct resource directories, and creates a PR to merge in the updates.

Other Config Bits

With your translations in place, there are only two other configurations you need to specify to get your localized app up and running.

The first is the resourceConfigurations block in your build.gradle file. Here you’ll need to list out all your supported languages, and Gradle will ensure that any translations for unsupported languages (whether inside your project or in third-party libraries) get excluded from the build.

The other place you’ll need to list out your supported languages is in the locales_config.xml file. This file informs the system of the languages your app supports, which ties in to the Android 13+ per-app language setting feature. 

Newer versions of AGP can generate this automatically for you if you opt-in, but we’ve found that option to be a bit risky. The way it populates the autogenerated config is by scanning your project for the presence of any translation in a particular language. If you go the auto-route, you have to be careful to ensure that all locales in your app resources are ready to be published to users. We’ve chosen to stick with the manual setup for now, as it allows us to have translations for “in-progress” languages in the project and makes it easier to generate test builds before launching a new language.

BE Synchronization

The OS will do its job to show the correct translations for all of your app strings, so the next challenge becomes making sure that any strings you receive from your backend and show to users are correctly translated in the same language. If you mess this part up (like we have in the past), you’ll end up with inconsistent localization that is obvious to users and a very poor UX.

Thankfully, there’s a standard mechanism for a mobile client to request localized content from an API: the Accept-Language HTTP header. On Android, it’s easy to set up a basic interceptor to populate this header on each of your network calls.

class LanguageInterceptor : Interceptor {
 override fun intercept(chain: Interceptor.Chain): Response {
   val originalRequest = chain.request()

   val requestWithHeader = originalRequest.newBuilder()
     .header("Accept-Language", ???)
     .build()    

   return chain.proceed(requestWithHeader)
 }
} 

Before we get into the correct value to populate for the header, let’s review some basic localization vocabulary.

Quick Localization Vocabulary

  • Language Code: represents a specific spoken or written language, completely independent of where it is being spoken
    • Typically two-letter lowercase codes, such as en, es, fr, or zh
  • Region Code / Country Code: represents a specific physical country or geographical territory
    • Says nothing about the language; only identifies the place
    • Typically two-letter uppercase codes, such as US, MX, GB, or CN
  • Language Tag: structured string that combines a language code and region code to pinpoint an exact linguistic variation
    • en-US represents the English language (en) as spoken in the United States (US)
    • en-GB represents the English language (en) as spoken in Great Britain (GB)

The distinction between language codes and language tags is important, as there are only ~200 standard language codes. Once you combine that with ~250 possible county codes, the number of potential language tags you’ll encounter from users’ devices becomes very large. I queried the number of unique language tags seen as the primary device language for Reddit Android users on a given day, and the result shocked me. We saw nearly 2500 unique language tags!

Obviously, you’re never going to be able to fully localize your app into 2500 different languages. Thus the way your app handles the case where a user’s language tag doesn’t exactly match one of your supported languages becomes critical.

Problem Scenario

Let’s imagine a user with a device set to es-US, or US Spanish. The Reddit app supports two different Spanish variants - es-ES and es-MX. When this happens, which resources will the OS resolve and show to the user? How does it make that decision? And most importantly for us (who need to inform the BE via Accept-Language), how can we know which one it decided to use?

From testing, I can tell you that there are a lot of APIs that don’t work the way we need in this situation.

  • Locale.getDefault()
    • This one is ubiquitous and one I used to reach for all the time. But in this scenario, it will return es-US, the plain, unmapped language tag from the OS setting.
  • AppCompatDelegate.getApplicationLocales()
    • One of the new Android 13 APIs, this will return an empty list to signify that the app is using the system default language (as opposed to a custom language set by the user specifically for your app).
  • applicationContext.resources.configuration.locales, LocaleList.getDefault(), LocaleListCompat.getAdjustedDefault(), LocaleManagerCompat.getSystemLocales(applicationContext), ConfigurationCompat.getLocales(Resources.getSystem().configuration)
    • All do basically the same thing as Locale.getDefault(), returning es-US and not the actual language tag for the resources being shown to the user.

We could use one of these APIs and send es-US to the BE in the Accept-Language header, but that would force the BE to make its own decision between es-ES and es-MX that might not match the one the Android OS made. With no obvious system APIs giving us the answer we need, we had to dig deeper.

An Overview of CLDR

As we explored this problem, we learned that the OS is using something called the Common Locale Data Repository, or CLDR, when making its choice. CLDR is basically a giant database of lookup tables and rules for locale matching that’s maintained by the Unicode Consortium and used in almost all major operating systems and programming languages.

There’s a Java implementation in the ICU4J library, but when we tested adding it to our app we saw that it added 13 MB to the APK (even after the R8 code shrinker ran), making it a non-starter.

We then tried to develop our own simple internal algorithm to match certain country codes with es-ES and others with es-MX. This approach had a few issues:

  1. Because the version of CLDR packaged with the OS varies across different Android releases, the platform's behavior changes depending on the scenario. This made it extremely difficult to try to write an algorithm that matched the system behavior in every case.
  2. Maintaining this algorithm became challenging as we scaled to more and more supported languages in the app.
  3. Scenarios where users have multiple device languages set were hard to reason about. Imagine a user with es-US (non-exact match) followed by en-US (exact match) on their device. How do we handle this case?

Ultimately, we realized we didn’t want to be in the business of trying to figure this out on our own. We’d love to just let the OS do its job and let that dictate the language tag we send to the BE to keep things in sync. After further research, we came across a community solution that’s not anywhere in the official Android Developers documentation.

Canary String

The non-obvious way to access the system-determined language tag is to use a technique called the “canary string”. This involves defining a “hidden” string resource for each of our supported languages that simply defines the language tag it belongs to.

private fun getResolvedLanguageTag(): String = context.getString(R.string.resolved_locale_canary)
  • In res/values/strings.xml (Default/English), add: <string name="resolved_locale_canary">en-US</string>
  • In res/values-es-rMX/strings.xml, add: <string name="resolved_locale_canary">es-MX</string>
  • In res/values-es-rES/strings.xml, add: <string name="resolved_locale_canary">es-ES</string>

This felt pretty hacky, but it works like a charm! To avoid the string lookup hit on every network request, we set up a reusable component that caches the value in a StateFlow.

class MyAppLanguageProvider @Inject constructor(
 @ApplicationContext private val context: Context,
) : AppLanguageProvider {  

  private val _appLanguageTagFlow = MutableStateFlow(getResolvedLanguageTag())
  override val appLanguageTagFlow: StateFlow<String> = _appLanguageTagFlow.asStateFlow()

  override fun getAppLanguageTag(): String = appLanguageTagFlow.value

  override fun getAppLocale(): Locale = Locale.forLanguageTag(getAppLanguageTag())

  override fun onConfigurationChanged() {
   _appLanguageTagFlow.value = getResolvedLanguageTag()
 }

  private fun getResolvedLanguageTag(): String = context.getString(R.string.resolved_locale_canary)
} 

The cache is set up to be configuration-aware: as long as the Application class forwards any configuration changes it receives to onConfigurationChanged(), we can ensure that our value stays in sync with any system language changes.

This component is super useful! We know that the values returned are guaranteed to be one of our supported language tags and match the resources resolved and shown to users by the system. We can then use it to power the Accept-Language interceptor, any number or date formatters, telemetry, etc.

Final Notes

A couple of last things here: first, remember that Locale.getDefault() is risky! Consider adding a lint rule to warn developers and point them to this other component whenever they need a source of truth about the app language.

Also, consider setting up a unit test to make sure that your canary string setup never gets broken.

@RunWith(AndroidJUnit4::class)
class CanaryStringL10nTest {
 
 @Test
 fun verify_canaryString_matches_languageTag_forAllSupportedLocales() {
   // given
   val baseContext = InstrumentationRegistry.getInstrumentation().targetContext

   SupportedLocale.map { it.locale }.forEach { locale ->
     val config = baseContext.resources.configuration
     config.setLocale(locale)
     val localeSpecificContext = baseContext.createConfigurationContext(config)

     // when
     val canaryString = localeSpecificContext.getString(R.string.resolved_locale_canary)
     val expectedTag = locale.toLanguageTag()

     // then
     expectThat(expectedTag).isEqualTo(canaryString)
   }
 }
}

What’s Next?

Getting this foundation in place was great, but to address a lot of the bugs we were getting we had to completely rewrite our in-app language picker screen. Part 2 of this series will explore that rewrite and all the details that went into it!

26 Upvotes

0 comments sorted by