Search code examples
javaandroidlocale

Android - Change application locale programmatically


Changing locale inside an Android app was never been easy. With androidx.appcompat:appcompat:1.3.0-alpha02, it seems that changing locale in an application has become much more difficult than I imagined. It appears that activity context and application context behaves very differently. If I change the locale of activities using a common BaseActivity (like below), it will work for the corresponding activity.

BaseActivity.java

public class BaseActivity extends AppCompatActivity {
    private Locale currentLocale;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        currentLocale = LangUtils.updateLanguage(this);
        super.onCreate(savedInstanceState);
    }

    @Override
    protected void attachBaseContext(Context newBase) {
        super.attachBaseContext(LangUtils.attachBaseContext(newBase));
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (currentLocale != LangUtils.getLocaleByLanguage(this)) recreate();
    }
}

But I need to change the locale of application context as well as this is only limited to activities. To do that, I can easily override Application#attachBaseContext() to update locale just as above.

MyApplication.java

public class MyApplication extends Application {
    private static MyApplication instance;

    @NonNull
    public static MyApplication getInstance() {
        return instance;
    }

    @NonNull
    public static Context getContext() {
        return instance.getBaseContext();
    }

    @Override
    public void onCreate() {
        instance = this;
        super.onCreate();
    }

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(LangUtils.attachBaseContext(base));
    }
}

While this successfully changes the locale of the application context, the activity context no longer respects the custom locale (regardless of whether I extend each activity from BaseActivity or not). Weird.

LangUtils.java

public final class LangUtils {
    public static final String LANG_AUTO = "auto";

    private static Map<String, Locale> sLocaleMap;
    private static Locale sDefaultLocale;

    static {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            sDefaultLocale = LocaleList.getDefault().get(0);
        } else sDefaultLocale = Locale.getDefault();
    }

    public static Locale updateLanguage(@NonNull Context context) {
        Resources resources = context.getResources();
        Configuration config = resources.getConfiguration();
        Locale currentLocale = getLocaleByLanguage(context);
        config.setLocale(currentLocale);
        DisplayMetrics dm = resources.getDisplayMetrics();
        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N){
            context.getApplicationContext().createConfigurationContext(config);
        } else {
            resources.updateConfiguration(config, dm);
        }
        return currentLocale;
    }

    public static Locale getLocaleByLanguage(Context context) {
        // Get language from shared preferences
        String language = AppPref.getNewInstance(context).getString(AppPref.PrefKey.PREF_CUSTOM_LOCALE_STR);
        if (sLocaleMap == null) {
            String[] languages = context.getResources().getStringArray(R.array.languages_key);
            sLocaleMap = new HashMap<>(languages.length);
            for (String lang : languages) {
                if (LANG_AUTO.equals(lang)) {
                    sLocaleMap.put(LANG_AUTO, sDefaultLocale);
                } else {
                    String[] langComponents = lang.split("-", 2);
                    if (langComponents.length == 1) {
                        sLocaleMap.put(lang, new Locale(langComponents[0]));
                    } else if (langComponents.length == 2) {
                        sLocaleMap.put(lang, new Locale(langComponents[0], langComponents[1]));
                    } else {
                        Log.d("LangUtils", "Invalid language: " + lang);
                        sLocaleMap.put(LANG_AUTO, sDefaultLocale);
                    }
                }
            }
        }
        Locale locale = sLocaleMap.get(language);
        return locale != null ? locale : sDefaultLocale;
    }

    public static Context attachBaseContext(Context context) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            return updateResources(context);
        } else {
            return context;
        }
    }

    @TargetApi(Build.VERSION_CODES.N)
    private static Context updateResources(@NonNull Context context) {
        Resources resources = context.getResources();
        Locale locale = getLocaleByLanguage(context);
        Configuration configuration = resources.getConfiguration();
        configuration.setLocale(locale);
        configuration.setLocales(new LocaleList(locale));
        return context.createConfigurationContext(configuration);
    }
}

Therefore, my conclusions are:

  1. If locale is set in the application context, regardless of whether you set activity context or not, locale will be set to application context only and not to activity (or any other) context.
  2. If locale isn't set in the application context but set in the activity context, the locale will be set to the activity context.

The workarounds that I can think of are:

  1. Set locale in the activity context and use them everywhere. But notifications, etc. will not work if there isn't any opened activity.
  2. Set locale in the application context and use it everywhere. But it means that you cannot take advantage of Context#getResources() for an activity.

EDIT(30 Oct 2020): Some people have suggested using a ContextWrapper. I've tried using one (like below) but still the same issue. As soon as I wrap the application context using the context wrapper, locale stops working for activities and fragments. Nothing changes.


public class MyContextWrapper extends ContextWrapper {
    public MyContextWrapper(Context base) {
        super(base);
    }

    @NonNull
    public static ContextWrapper wrap(@NonNull Context context) {
        Resources res = context.getResources();
        Configuration configuration = res.getConfiguration();
        Locale locale = LangUtils.getLocaleByLanguage(context);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            configuration.setLocale(locale);
            LocaleList localeList = new LocaleList(locale);
            LocaleList.setDefault(localeList);
            configuration.setLocales(localeList);
        } else {
            configuration.setLocale(locale);
            DisplayMetrics dm = res.getDisplayMetrics();
            res.updateConfiguration(configuration, dm);
        }
        configuration.setLayoutDirection(locale);
        context = context.createConfigurationContext(configuration);
        return new MyContextWrapper(context);
    }
}

Solution

  • A blog article, how to change the language on Android at runtime and don’t go mad, addressed the issue (along with others) and the author created a library called Lingver to solve the issues.

    EDIT (13 Feb 2023): AndroidX Appcompat library 1.6.0 introduced an option to change locale dynamically. However, as of today, it can only apply locale for activities only. I've rewritten the class to support the library, but be aware that you can't use the automatic handling of languages provided with the library because it requires the app to initialise first. So, why use the library? For two reasons:

    1. Compatibility with Android 13
    2. It greatly simplifies a few things as the library can access the delegate class.

    EDIT (3 Jun 2022): Lingver library has completely failed to address a few issues and appears to be inactive for some time. After a thorough investigation, I have came up with my own implementation: (You can copy the code below under the terms of either Apache-2.0 or GPL-3.0-or-later license)

    LangUtils.java

    public final class LangUtils {
        public static final String LANG_AUTO = "auto";
        public static final String LANG_DEFAULT = "en";
    
        private static ArrayMap<String, Locale> sLocaleMap;
    
        public static void setAppLanguages(@NonNull Context context) {
            if (sLocaleMap == null) sLocaleMap = new ArrayMap<>();
            Resources res = context.getResources();
            Configuration conf = res.getConfiguration();
            // Assume that there is an array called language_key which contains all the supported language tags
            String[] locales = context.getResources().getStringArray(R.array.languages_key);
            Locale appDefaultLocale = Locale.forLanguageTag(LANG_DEFAULT);
    
            for (String locale : locales) {
                conf.setLocale(Locale.forLanguageTag(locale));
                Context ctx = context.createConfigurationContext(conf);
                String langTag = ctx.getString(R.string._lang_tag);
    
                if (LANG_AUTO.equals(locale)) {
                    sLocaleMap.put(LANG_AUTO, null);
                } else if (LANG_DEFAULT.equals(langTag)) {
                    sLocaleMap.put(LANG_DEFAULT, appDefaultLocale);
                } else sLocaleMap.put(locale, ConfigurationCompat.getLocales(conf).get(0));
            }
        }
    
        @NonNull
        public static ArrayMap<String, Locale> getAppLanguages(@NonNull Context context) {
            if (sLocaleMap == null) setAppLanguages(context);
            return sLocaleMap;
        }
    
        @NonNull
        public static Locale getFromPreference(@NonNull Context context) {
            if (BuildCompat.isAtLeastT()) {
                Locale locale = AppCompatDelegate.getApplicationLocales().getFirstMatch(getAppLanguages(context).keySet()
                        .toArray(new String[0]));
                if (locale != null) {
                    return locale;
                }
            }
            // Fall-back to shared preferences
            String language = // TODO: Fetch current language from the shared preferences
            Locale locale = getAppLanguages(context).get(language);
            if (locale != null) {
                return locale;
            }
            // Load from system configuration
            Configuration conf = Resources.getSystem().getConfiguration();
            //noinspection deprecation
            return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N ? conf.getLocales().get(0) : conf.locale;
        }
    
        private static Locale applyLocale(Context context) {
            return applyLocale(context, LangUtils.getFromPreference(context));
        }
    
        public static Locale applyLocale(@NonNull Context context, @NonNull Locale locale) {
            AppCompatDelegate.setApplicationLocales(LocaleListCompat.create(locale));
            updateResources(context.getApplicationContext(), locale);
            return locale;
        }
    
        private static void updateResources(@NonNull Context context, @NonNull Locale locale) {
            Locale.setDefault(locale);
    
            Resources res = context.getResources();
            Configuration conf = res.getConfiguration();
            //noinspection deprecation
            Locale current = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N ? conf.getLocales().get(0) : conf.locale;
    
            if (current == locale) {
                return;
            }
    
            conf = new Configuration(conf);
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                setLocaleApi24(conf, locale);
            } else {
                conf.setLocale(locale);
            }
            //noinspection deprecation
            res.updateConfiguration(conf, res.getDisplayMetrics());
        }
    
        @RequiresApi(Build.VERSION_CODES.N)
        private static void setLocaleApi24(@NonNull Configuration config, @NonNull Locale locale) {
            LocaleList defaultLocales = LocaleList.getDefault();
            LinkedHashSet<Locale> locales = new LinkedHashSet<>(defaultLocales.size() + 1);
            // Bring the target locale to the front of the list
            // There's a hidden API, but it's not currently used here.
            locales.add(locale);
            for (int i = 0; i < defaultLocales.size(); ++i) {
                locales.add(defaultLocales.get(i));
            }
            config.setLocales(new LocaleList(locales.toArray(new Locale[0])));
        }
    }
    

    MyApplication.java

    public class MyApplication extends Application {
        @Override
        public void onCreate() {
            super.onCreate();
            LangUtils.applyLocale(this);
        }
    }
    

    In your preference where you are changing the language, you can simply apply the locale like this:

    LangUtils.applyLocale(context, newLocale);
    

    Activites that use Android WebView After loading the webview via Activity.findViewById() you can add the following line immediately:

    // Fix locale issue due to WebView (https://issuetracker.google.com/issues/37113860)
    LangUtils.applyLocale(context);