Welcome back!

Hi everyone and welcome back! I haven’t been active for some time due the business obligations so I apologize for that. In today’s blog we’re gonna talk about how Android by itself handle the localization, how knows which resources needs to be displayed and how to change it on runtime. UiMode in title references switch for Dark Theme so this topic will also be covered.

Before we begin, changing the language on Android at runtime was never officially encouraged or documented. Android OS is trying to show correct resources depending on System language on your phone. I think this is important to know!

Android Resources, Context and Configuration

When Android OS launches the application, it’s trying to display correct strings, drawables and assets stored in Resources folder depending on system language. This folder is familiar to all Android developers since we put literally everything there for our Presentation layer. So, how exactly Android tells our Presentation layer which system language is active on device? Through Context!

What is exactly Context? Well, we can define Context as abstract interface in Android system which gives information about application resources, classes etc. Context is provided by Android system to our Application, Acivities and Fragments by default and needed to show correctly resources in Presentation layer. We differentiate two types of Context available in Android framework:

  1. Application context – attached to the application’s lifecycle and will always be same throughout the life of application.
  2. Activity context – attached to the Activity’s lifecycle and can be destroyed if the activity’s onDestroy() is raised.

Now when we’re aware of that there is two type of Context present in our Android app, we can move forward. Every Context class contains Resources. Every Resources class has it’s own Configuration. Configuration describes all device configuration information that can impact the resources the application retrieves. This includes both user-specified configuration options (locale list and scaling) as well as device configurations (such as input modes, screen size and screen orientation).

Configuration class contains information such as Locale of a Context and uiMode that is active. This two options are crucial for this topic.

Changing Locale and uiMode in runtime

Crucial part in whole process are these two classes: Context and it’s Configuration. Context is passed in creation of our Presentation layer components: Application, Activity and Fragment. We need somehow intercept passing Context and change it’s Configuration to fits our Settings. Well, we can actually do that!

Magic happens in method attachBaseContext(base: Context?). Android OS called this method during creation of our components to make sure that Context is attached only once. Param base in upper mentioned method is original Context passed to our component. We can change it with our configuration and passed it back to parent class. And that’s we gonna do!

Don’t forget to add configChanges for locale and uiMode in your AndroidManifest file to every Activity. We don’t want let system to recreate our Activities when these config changes occurred when we’re handling it on our own!

<activity
    android:name=".main.presentation.ui.MainActivity"
    android:configChanges="layoutDirection|uiMode|locale"/>

So our Application and BaseActivity classes looks something like this. SettingsManager is my singleton class for all current active settings in app and it’s injected with Koin. It will be explained later.

class App: Application() {
    
    private val settingsManager: SettingsManager by inject()

    override fun attachBaseContext(base: Context?) {
        super.attachBaseContext(settingsManager.attachBaseContext(base))
    }
}
abstract class BaseActivity: AppCompatActivity() {

    protected val settingsManager: SettingsManager by inject()

    override fun attachBaseContext(base: Context?) {
       super.attachBaseContext(settingsManager.attachBaseContext(base))
    }
}

Since Fragment is hosted by Activity, it’s receives Activity context during creation so we don’t need to do nothing in our BaseFragment class. You can override this method onAttach(context: Context) in Fragment class just to check that Context is same!

Now we move on to the funny part! Are you ready? Bet you do. SettingsManager is interface that contains following methods. Implementation is made elsewhere. You can add method if your requirements are different. So it look something like this:

interface SettingsManager {

    /**
     * Method called in [Application.attachBaseContext] and [BaseActivity.attachBaseContext]
     * to wrap original [Context] with user preferences for [Locale] and [Configuration.uiMode].
     * @param base original Context passed to our component
     * @return changed Context with user [Locale] and [Configuration.uiMode]
     */
    fun attachBaseContext(base: Context?): Context?

    /**
     * Method called when user changed app [Locale].
     * @param context of a component that called this method
     * @param languageCode ISO 639 language code that will be set
     */
    fun changeLanguage(context: Context?, languageCode: String?)

    /**
     * Method called when user changed [Configuration.uiMode].
     * @param context of a component that called this method
     * @param uiMode that will be set. It can be one of following:
     * {[Configuration.UI_MODE_NIGHT_NO] or [Configuration.UI_MODE_NIGHT_YES]}
     */
    fun changeUiMode(context: Context?, uiMode: Int?)
}

And here are the implementation. I wrote comments to make it easier to understand what we are doing in these methods:

class SettingsManagerImpl: SettingsManager {

    override fun attachBaseContext(base: Context?): Context? {
        return updateSettings(base)
    }

    override fun changeLanguage(context: Context?, language: Language?) {
        PreferencesUtils.setLanguage(context, language)
        updateSettings(context)
    }

    override fun changeUiMode(context: Context?, uiMode: Int?) {
        PreferencesUtils.setUiMode(context, uiMode)
        updateSettings(context)
    }

    /**
     * Method called in [attachBaseContext] to wrap original [Context] with user settings.
     * @param base original [Context] passed to our component
     * @return wrapped [Context] with user preferences or NULL if original context is NULL.
     */
    @Suppress("DEPRECATION")
    private fun updateSettings(base: Context?): Context? {
        // get stored locale from Preferences
        val locale = PreferencesUtils.getLocale(base)
        // set that Locale as default
        Locale.setDefault(locale)
        // get resources class of given context
        val res = base?.resources
        // create new configuration passing old configuration from original Context
        val config = Configuration(res?.configuration)
        // update config uiMode with user stored one
        config.uiMode = PreferencesUtils.getUiMode(base)
        // set default night mode
        setDefaultNightMode(config.uiMode)
        // finally, update locale depends on which Android version is installed and return Context
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            setLocaleForApi24(config, locale)
            base?.createConfigurationContext(config)
        } else {
            config.setLocale(locale)
            base?.createConfigurationContext(config)
        }
    }

    /**
     * Method called from [updateSettings] when we set uiMode to notify our delegate to correctly
     * show Dark or Light Theme.
     * @param uiMode that is set.
     */
    private fun setDefaultNightMode(uiMode: Int?){
        if (uiMode == Configuration.UI_MODE_NIGHT_NO)
            AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
        else AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
    }

    /**
     * Method called from [updateSettings] when we want to set [Locale] that user configured.
     * Requires Android with version N or greater.
     * @param config config file for which param [locale] will be set
     * @param locale user set Locale
     */
    @RequiresApi(api = Build.VERSION_CODES.N)
    private fun setLocaleForApi24(
        config: Configuration,
        locale: Locale
    ) {
        val set: MutableSet<Locale> = LinkedHashSet()
        // bring the user locale to the front of the list
        set.add(locale)
        val all = LocaleList.getDefault()
        for (i in 0 until all.size()) {
            // append other locales supported by the user
            set.add(all[i])
        }
        val locales = set.toTypedArray()
        config.setLocales(LocaleList(*locales))
    }
}

All right! Everything set. Now in your components when want to change Locale or uiMode, just call these two methods in SettingsManager below:

changeLanguage(context, Locale.ENGLISH.language)
changeUiMode(context, Configuration.UI_MODE_NIGHT_YES)

and after that, call recreate() on Activity's class to affect changes!

Let's test!

All right. Now we can test these functionalities. We’ll be logging onAttachBaseContext method in Application, Activity and Fragment class to see Configuration parameters which we are changed (Locale and uiMode) that fits our Settings.

Initial test parameters are that we have system language set to Croatian and LIGHT THEME on. So parameters in config file must be:

Locale = hr
uiMode = Configuration.UI_MODE_NIGHT_NO

When we start our application, these are the logs:

D/App: Locale = hr
D/App: uiMode = Configuration.UI_MODE_NIGHT_NO
D/MainActivity: Locale = hr
D/MainActivity: uiMode = Configuration.UI_MODE_NIGHT_NO
D/MainFragment: Locale = hr 
D/MainFragment: uiMode = Configuration.UI_MODE_NIGHT_NO

So far so good. Now. let’s change uiMode to DARK. Test method will call settingsManager.changeUiMode(activity, Configuration.UI_MODE_NIGHT_YES) from Fragment class and than call activity?.recreate(). Logs are as follows:

D/MainActivity: Locale = hr
D/MainActivity: uiMode = Configuration.UI_MODE_NIGHT_YES
D/MainFragment: Locale = hr
D/MainFragment: uiMode = Configuration.UI_MODE_NIGHT_YES

Everything’s going fine. Lastly. let’s change Locale to ENGLISH. Test method will call settingsManager.changeLanguage(activity, Locale.ENGLISH.language) from Fragment class and then call activity?.recreate(). Logs are as follows:

D/MainActivity: Locale = en
D/MainActivity: uiMode = Configuration.UI_MODE_NIGHT_YES
D/MainFragment: Locale = en
D/MainFragment: uiMode = Configuration.UI_MODE_NIGHT_YES

Wow, everything is just as we expected. Or is it? Are you noticed something weird? Every time we call recreate() method on our Activity class, only Activity and Fragment are recreated and attachBaseContext(context) is called, onAttach(context) in FragmentWhere is Application’s call of mentioned method?

What about Application and applicationContext?

As I said before, Application class is singleton and applicationContext() will always be same through the life of application. That means that it will not be updated with our new config files that we set. It will be updated only next time when we start application and previously get killed by Android OS or user.

We can see that in our logs when we performed change of Locale or uiMode. When we call context.applicationContext on newly updated Context and log values, they are the same as on the beginning:

D/MainActivity: Locale context = en
D/MainActivity: Locale application context = hr 
D/MainActivity: uiMode context = Configuration.UI_MODE_NIGHT_YES
D/MainActivity: uiMode application context = Configuration.UI_MODE_NIGHT_NO

After killing process and started it again, everything is fine:

D/MainActivity: Locale context = en
D/MainActivity: Locale application context = en 
D/MainActivity: uiMode context = Configuration.UI_MODE_NIGHT_YES
D/MainActivity: uiMode application context = Configuration.UI_MODE_NIGHT_YES

Conclusion

Conclusion is that if we want to see changes in all Context Resources, we must kill process and start it again. Because of restriction that Application is singleton object, also applicationContext, we can’t update it during changes, only first time when created. I’m hoping that now problematic of language switch in-app is more clear. Thank you for reading and see you in another post!