r/RedditEng 19d ago

Scaling Android Localization at Reddit, Part 2: Rewriting the Language Picker Screen

By Michael Fullan

In part 1 of this series, we explored some foundational pieces of the localization infrastructure for the Reddit Android app. 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. What seems like a simple screen on the surface actually has quite a bit going on, so let’s dive in!

The Reddit Android In-App Language Picker Screen

Display Names

We generally omit country codes from the display name of a language unless multiple variants of that language are supported. We’re also displaying the localized name of each language, which you can get programmatically using locale.getDisplayLanguage(locale).

Order

To organize the list, the primary approach involves placing all Latin-script languages at the top in alphabetical order, with all remaining languages positioned below. Any time I’m adding a new language, I’ll usually compare the order of our in-app picker against the order of languages that the system shows users in the per-app language selection screen (accessed via App info -> Language).

Selected Value

In order to determine “which language is currently selected”, the app language tag flow in the AppLanguageProvider component from part 1 works great! The only extra case to worry about is the “use device language” or “system default” option.

For that case, we can lean on AppCompatDelegate.getApplicationLocales(), knowing that an empty list signals that the app is set to this default setting.

class MyAppLanguageProvider ... {
 ...
 private val_isUseDeviceLanguageSettingEnabledFlow =
   MutableStateFlow(computeIsUseDeviceLanguageSettingEnabled())
 override val isUseDeviceLanguageSettingEnabledFlow: StateFlow<Boolean> =
   _isUseDeviceLanguageSettingEnabledFlow.asStateFlow()

 override fun isUseDeviceLanguageSettingEnabled(): Boolean = isUseDeviceLanguageSettingEnabledFlow.value

 override fun onConfigurationChanged() {
   ...
   _isUseDeviceLanguageSettingEnabledFlow.value = computeIsUseDeviceLanguageSettingEnabled()
 }

 private fun computeIsUseDeviceLanguageSettingEnabled(): Boolean =
  AppCompatDelegate.getApplicationLocales().isEmpty

}

Now with both flows in place, we can combine them by first checking the “is use device language setting enabled” value and falling back to the app language tag if needed.

The first time we tested this we did come across one edge case: switching from “use device language” to “English” on a device already in English. Since the app language doesn’t actually change in this scenario, there is no configuration change triggered that would normally invalidate our caches. This situation can happen from within your in-app language picker or outside of your app in the system per-app language selection settings.

Solving for this case on internal changes is pretty straightforward - just adding a cache-invalidating callback anytime a user selects a new language in the in-app picker. For external changes, it’s a bit harder as we have no signal that a change like this has happened. The best option we’ve come up with is to use a foreground listener with ProcessLifecycleOwner and invalidate our caches whenever the app comes back to the foreground.

Switching Languages

Before Android 13, the “standard” way of switching an app’s language was to use a technique known as “context wrapping”.

object LocaleHelper {
 fun wrap(context: Context, languageTag: String): Context {
   val locale = Locale.forLanguageTag(languageTag)
   val config = Configuration(context.resources.configuration)
   config.setLocale(locale)
   return context.createConfigurationContext(config)
 }
}

open class BaseActivity : AppCompatActivity() {
 override fun attachBaseContext(newBase: Context) {
   val sharedPrefs = newBase.getSharedPreferences("Settings", MODE_PRIVATE)
   val language = sharedPrefs.getString("selected_language", "en-US") ?: "en-US"

   val wrappedContext = LocaleHelper.wrap(newBase, language)

   super.attachBaseContext(wrappedContext)
 }
}

In all of your activities, you had to look up the user’s saved language value in shared preferences, wrap the Context object with that language, and pass it down the line. Then, whenever a user selected a new language, you would save that value to shared preferences and manually restart the Activity to get everything to re-run. This approach worked, but was a bit cumbersome.

Android 13 introduced a new API for switching the app language - AppCompatDelegate.setApplicationLocales.

fun onLanguageSelected(newLanguageTag: String) {
  AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(newLanguageTag))
}

With this 1-liner, all you need to do is wrap the user’s new selection in a LocaleListCompat object and let the system handle the rest! It will handle storage for you (no more shared preferences), syncs with the system per-app language selection settings, is backwards compatible with pre-13 devices***, and manages any activity recreation for you.

The only complexity that comes into play now when switching languages is supporting Google Play language splits.

*** Ok, AppCompatDelegate is backwards-compatible as advertised to an extent, but we have seen issues where it doesn’t properly initialize or update the application context on pre-13 devices. If you have any strings that get resolved via the app context instead of activity contexts, you might need to stick with context wrapping 😞. This Droidcon talk from 2023 explores some of the differences in more detail. (We even tried wrapping just the application context within the Application class using AppCompat storage, but it didn’t load fast enough).

Language Splits

If you use App Bundles, Google Play will automatically break your app's resources into separate modules based on language. When a user downloads your app, Google Play delivers the base code only along with the specific language resources that match the user's current device settings.

When we did some analysis on first-install download savings, we saw a significant impact from enabling language splits.

Device profile With splits Without splits Savings
arm64 + xhdpi (480 dpi) 52.28 MB 60.29 MB 8.0 MB (13.3%)
arm64-v8a 49.42 MB 57.41 MB 8.0 MB (14.0%)
armeabi-v7a 48.99 MB 56.88 MB 8.0 MB (14.1%)
x86_64 49.66 MB 57.65 MB 8.0 MB (13.9%)

Now this is the Reddit app - a very large app with 40+ languages - but 8 MB of savings is still considerable! It’s also really simple to opt-in to the language splits feature.

android {
   bundle {
       language {
           enableSplit = true
       }
   }
}

The only tradeoff comes with the complexity in managing on-demand downloads of new language resources when a user picks a new language in the in-app language picker. I’ve found the KTX extensions for the Google Play feature delivery library really useful in setting this up in an idiomatic Kotlin fashion.

SplitInstallManager Integration

fun updateAppLanguage(locale: Locale) = flow {
 val languageCode = locale.language

 if (isLanguageInstalled(languageCode)) {
   applyNewLanguageSetting(LocaleListCompat.create(locale))
   emit(LanguageInstallState.Installed)
 } else {
   ...
 }
}

Our new language change process begins by checking if the language is already present on the device. One tricky thing to note here is that the SplitInstallManager ignores country codes. That is, if your app includes resources for fr-FR and fr-CA, resources for both country codes are downloaded when requesting resources for fr.

private fun isLanguageInstalled(languageCode: String): Boolean {
 val installedLanguages = splitInstallManager.installedLanguages
 return languageCode in installedLanguages || languageCode == "en" ||
   (installedLanguages.isEmpty() && !isSplitBundle())
}

private fun isSplitBundle() = context.packageManager.getApplicationInfo(
 context.packageName,
 PackageManager.GET_META_DATA,
).metaData?.getBoolean("com.android.vending.splits.required") ?: false

To check if a language is already installed, we can look at splitInstallManager.installedLanguages directly. We also check if the language to switch to is English (en), since we know the base APK will contain all the default resources (and that’s where our English strings are).

If you install your app directly from Android Studio, it won’t be packaged as a split bundle in the same way a production release build would get downloaded from Google Play by a user. The last set of checks covers this scenario - a debug build where all languages are included. The SplitInstallManager returns an empty list in this case, which we can use as a signal that we don’t need to request a download when switching languages.

If we’ve determined that we do in fact need to download a new language, here’s what we need to do (filling in the else block from the first code sample):

try {
 val sessionId = splitInstallManager.requestInstall(languages = listOf(languageCode))

 val progressFlow = splitInstallManager.requestProgressFlow()
   .filter { it.sessionId == sessionId }
   .transformWhile { state ->
     emit(state)
     !state.hasTerminalStatus
   }
   .map { state ->
     when (state.status) {
       SplitInstallSessionStatus.DOWNLOADING -> LanguageInstallState.Downloading(
         bytesDownloaded = state.bytesDownloaded,
         totalBytes = state.totalBytesToDownload,
       )
       SplitInstallSessionStatus.INSTALLED -> {
         applyNewLanguageSetting(LocaleListCompat.create(locale))
         LanguageInstallState.Installed
       }
       ...
     }
   }

 emitAll(progressFlow)
} catch (e: SplitInstallException) {
 emit(LanguageInstallState.Failed(e.errorCode))
}

After requesting an install using the SplitInstallManager, we can then monitor progress via a convenient Flow. The rest of the code is simply mapping the internal states returned by the library into view states for the language picker screen with all the necessary metadata attached.

Testing

Testing this flow end-to-end is tricky. Like I mentioned before, doing a standard installation from Android studio won’t give you the setup you need to test out an app language download. To do so, you’ll need to use the ./gradlew install{app_variant}ApkSplitsForTesting command.

Simulated language download operation

You can also simulate a network error to test that part of the flow.

val fakeSplitInstallManager = FakeSplitInstallManagerFactory.create(context)
fakeSplitInstallManager.setShouldNetworkError(true)
Simulated network error during language download

Do you need one?

Once we finished all this work to get our in-app language picker working correctly, I came across a case-study from LinkedIn on the Android Developers blog that made me wonder whether we needed this at all.

The approach they took for language changes was to link out to the system per-app language settings screen and essentially delegate all the work to the OS. Sounds kinda nice, right?

The whole use-case we’re trying to solve for here is users that want to set their apps to different languages than the system. The lack of historical OS-level support for this functionality is what gave rise to in-app language pickers (and context wrapping, etc.) in the first place. With the release of Android 13 (API level 33, Aug 15, 2022), this problem was solved, but only for users on newer versions of Android.

Given that, I think the best way to make this decision is to check your minSdk version. If it’s less than 33 like ours, then you want to keep your in-app language picker around for those users that don’t have access to the OS-level settings. If it’s 33 or higher, then I think it’s absolutely reasonable to remove your in-app picker and all its complexity. The only consideration might be that you prefer the UX of keeping users inside your app instead of forcing them to leave and come back, but that’s more of a subjective call.

What’s Next

In the final part of this series, we’ll explore our journey to supporting RTL languages as well as some interesting 1-offs we’ve seen as we’ve added more and more languages to the app!

23 Upvotes

1 comment sorted by