Change Locale not work after migrate to Androidx

UPDATE Sep 5 2022:

AppCompat 1.5.0 was released, which includes these changes, specifically:

This stable version includes improvements to night mode stability

[...]

Fixes an issue where AppCompat’s context wrapper reused the application context's backing resource implementation, resulting in uiMode being overwritten on the application context. (Idf9d5)

Digging deeper inside the code reveals several crucial changes which essentially mean that, similar to before, if you're not using a ContextWrapper or ContextThemeWrapper AND don't offer users to set a different night mode than they've set on their device THEN all context wrapping and theming and locale updates should now work properly and you should be able to safely remove all your workarounds explained below or any other hacks you might have in place.

Unfortunately, if you're using any kind of ContextWrapper OR allowing users to manually set the night mode of your app, then there still seem to be issues, I'm not sure why Google has such trouble fixing this. The problem I've faced: If uiMode is NOT overwritten anymore, that means when you:

  • set your device to dark mode,
  • set your app to light mode (night mode disabled),
  • rotate your phone to landscape,
  • lock your screen,
  • unlock your screen again while still in landscape,

then depending on your device and possibly Android version you may see the application context's uiMode being stale and ending up with awesome black on black and white on white theming glitches. This also happens for device in light mode and app in night mode. In addition, your app's locale may be reset to the device locale. In these cases you need to use the solution described below.

Working solution for AppCompat 1.2.0-1.5.0 when encountering issues with locale or day/night:

If you use a ContextWrapper or ContextThemeWrapper inside attachBaseContext in AppCompat 1.2.0-1.4.2, locale changes will break, because when you pass your wrapped context to super,

  1. the 1.2.0-1.4.2 AppCompatActivity makes internal calls which wrap your ContextWrapper in another ContextThemeWrapper and AppCompatDelegateImpl thus ending up in your locale being ignored,
  2. or if you use a ContextThemeWrapper, overrides its configuration to a blank one, similar to what happened back in 1.1.0.

Whether you use a context wrapper or not in 1.5.0, as described in my latest update, there may still be theme glitches and app locale being reset. The solution either way is always the same, nothing else worked for me. The big obstacle is that, unlike in 1.1.0, applyOverrideConfiguration is called on your base context, not your host activity, so you can't just override that method in your activity and fix the locale (or uiMode) as you could in 1.1.0. The only working solution I'm aware of is to reverse the wrapping by overriding getDelegate() to make sure your wrapping and/or locale override comes last. First, you add the class below:

Kotlin sample (please note that the class MUST be inside the androidx.appcompat.app package because the only existing AppCompatDelegate constructor is package private)

package androidx.appcompat.app

import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.util.AttributeSet
import android.view.MenuInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.Toolbar

class BaseContextWrappingDelegate(private val superDelegate: AppCompatDelegate) : AppCompatDelegate() {

    override fun getSupportActionBar() = superDelegate.supportActionBar

    override fun setSupportActionBar(toolbar: Toolbar?) = superDelegate.setSupportActionBar(toolbar)

    override fun getMenuInflater(): MenuInflater? = superDelegate.menuInflater

    override fun onCreate(savedInstanceState: Bundle?) {
        superDelegate.onCreate(savedInstanceState)
        removeActivityDelegate(superDelegate)
        addActiveDelegate(this)
    }

    override fun onPostCreate(savedInstanceState: Bundle?) = superDelegate.onPostCreate(savedInstanceState)

    override fun onConfigurationChanged(newConfig: Configuration?) = superDelegate.onConfigurationChanged(newConfig)

    override fun onStart() = superDelegate.onStart()

    override fun onStop() = superDelegate.onStop()

    override fun onPostResume() = superDelegate.onPostResume()

    override fun setTheme(themeResId: Int) = superDelegate.setTheme(themeResId)

    override fun <T : View?> findViewById(id: Int) = superDelegate.findViewById<T>(id)

    override fun setContentView(v: View?) = superDelegate.setContentView(v)

    override fun setContentView(resId: Int) = superDelegate.setContentView(resId)

    override fun setContentView(v: View?, lp: ViewGroup.LayoutParams?) = superDelegate.setContentView(v, lp)

    override fun addContentView(v: View?, lp: ViewGroup.LayoutParams?) = superDelegate.addContentView(v, lp)

    override fun attachBaseContext2(context: Context) = wrap(superDelegate.attachBaseContext2(super.attachBaseContext2(context)))

    override fun setTitle(title: CharSequence?) = superDelegate.setTitle(title)

    override fun invalidateOptionsMenu() = superDelegate.invalidateOptionsMenu()

    override fun onDestroy() {
        superDelegate.onDestroy()
        removeActivityDelegate(this)
    }

    override fun getDrawerToggleDelegate() = superDelegate.drawerToggleDelegate

    override fun requestWindowFeature(featureId: Int) = superDelegate.requestWindowFeature(featureId)

    override fun hasWindowFeature(featureId: Int) = superDelegate.hasWindowFeature(featureId)

    override fun startSupportActionMode(callback: ActionMode.Callback) = superDelegate.startSupportActionMode(callback)

    override fun installViewFactory() = superDelegate.installViewFactory()

    override fun createView(parent: View?, name: String?, context: Context, attrs: AttributeSet): View? = superDelegate.createView(parent, name, context, attrs)

    override fun setHandleNativeActionModesEnabled(enabled: Boolean) {
        superDelegate.isHandleNativeActionModesEnabled = enabled
    }

    override fun isHandleNativeActionModesEnabled() = superDelegate.isHandleNativeActionModesEnabled

    override fun onSaveInstanceState(outState: Bundle?) = superDelegate.onSaveInstanceState(outState)

    override fun applyDayNight() = superDelegate.applyDayNight()

    override fun setLocalNightMode(mode: Int) {
        superDelegate.localNightMode = mode
    }

    override fun getLocalNightMode() = superDelegate.localNightMode

    private fun wrap(context: Context): Context {
        TODO("your wrapping implementation here")
    }
}

Then inside our base activity class (make sure you remove any other previous workarounds first) add this code:

private var baseContextWrappingDelegate: AppCompatDelegate? = null

override fun getDelegate() = baseContextWrappingDelegate ?: BaseContextWrappingDelegate(super.getDelegate()).apply {
    baseContextWrappingDelegate = this
}

// OPTIONAL createConfigurationContext and/or onStart code below may be needed depending on your ContextWrapper implementation to avoid issues with themes

override fun createConfigurationContext(overrideConfiguration: Configuration) : Context {
    val context = super.createConfigurationContext(overrideConfiguration)
    TODO("your wrapping implementation here")
}

private fun fixStaleConfiguration() {
    // we only want to fix the configuration if our app theme is different than the system theme, otherwise it will result in an infinite configuration change loop causing a StackOverflowError and crashing your app
    if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
        applicationContext?.configuration?.uiMode = resources.configuration.uiMode
    TODO("your locale updating implementation here")
    // OPTIONAL if you are using a context wrapper, the wrapper might have stale resources with wrong uiMode and/or locale which you need to clear or update at this stage
}

override fun onRestart() {
    fixStaleConfiguration()
    super.onRestart()
}

// OPTIONAL if you have specified configChanges in your manifest, especially orientation and uiMode
override fun onConfigurationChanged(newConfig: Configuration) {
    fixStaleConfiguration()
    super.onConfigurationChanged(newConfig)
}

For an example how to successfully "clear stale resources", which may or may not be needed in your case:

(context as? ContextThemeWrapper)?.run {
    if (mContextThemeWrapperResources == null) {
        mContextThemeWrapperResources = ContextThemeWrapper::class.java.getDeclaredField("mResources")
        mContextThemeWrapperResources!!.isAccessible = true
    }
    mContextThemeWrapperResources!!.set(this, null)
} ?: (context as? androidx.appcompat.view.ContextThemeWrapper)?.run {
    if (mAppCompatContextThemeWrapperResources == null) {
        mAppCompatContextThemeWrapperResources = androidx.appcompat.view.ContextThemeWrapper::class.java.getDeclaredField("mResources")
        mAppCompatContextThemeWrapperResources!!.isAccessible = true
    }
    mAppCompatContextThemeWrapperResources!!.set(this, null)
}
(context as? AppCompatActivity)?.run {
    if (mAppCompatActivityResources == null) {
        mAppCompatActivityResources = AppCompatActivity::class.java.getDeclaredField("mResources")
        mAppCompatActivityResources!!.isAccessible = true
    }
    mAppCompatActivityResources!!.set(this, null)
}

OLD ANSWER AND CONFIRMED WORKING SOLUTION FOR APPCOMPAT 1.1.0:

Basically what's happening in the background is that while you've set the configuration correctly in attachBaseContext, the AppCompatDelegateImpl then goes and overrides the configuration to a completely fresh configuration without a locale:

 final Configuration conf = new Configuration();
 conf.uiMode = newNightMode | (conf.uiMode & ~Configuration.UI_MODE_NIGHT_MASK);

 try {
     ...
     ((android.view.ContextThemeWrapper) mHost).applyOverrideConfiguration(conf);
     handled = true;
 } catch (IllegalStateException e) {
     ...
 }

In an unreleased commit by Chris Banes this was actually fixed: The new configuration is a deep copy of the base context's configuration.

final Configuration conf = new Configuration(baseConfiguration);
conf.uiMode = newNightMode | (conf.uiMode & ~Configuration.UI_MODE_NIGHT_MASK);
try {
    ...
    ((android.view.ContextThemeWrapper) mHost).applyOverrideConfiguration(conf);
    handled = true;
} catch (IllegalStateException e) {
    ...
}

Until this is released, it's possible to do the exact same thing manually. To continue using version 1.1.0 add this below your attachBaseContext:

Kotlin solution

override fun applyOverrideConfiguration(overrideConfiguration: Configuration?) {
    if (overrideConfiguration != null) {
        val uiMode = overrideConfiguration.uiMode
        overrideConfiguration.setTo(baseContext.resources.configuration)
        overrideConfiguration.uiMode = uiMode
    }
    super.applyOverrideConfiguration(overrideConfiguration)
}

Java solution

@Override
public void applyOverrideConfiguration(Configuration overrideConfiguration) {
    if (overrideConfiguration != null) {
        int uiMode = overrideConfiguration.uiMode;
        overrideConfiguration.setTo(getBaseContext().getResources().getConfiguration());
        overrideConfiguration.uiMode = uiMode;
    }
    super.applyOverrideConfiguration(overrideConfiguration);
}

This code does exactly the same what Configuration(baseConfiguration) does under the hood, but because we are doing it after the AppCompatDelegate has already set the correct uiMode, we have to make sure to take the overridden uiMode over to after we fix it so we don't lose the dark/light mode setting.

Please note that this only works by itself if you don't specify configChanges="uiMode" inside your manifest. If you do, then there's yet another bug: Inside onConfigurationChanged the newConfig.uiMode won't be set by AppCompatDelegateImpl's onConfigurationChanged. This can be fixed as well if you copy all the code AppCompatDelegateImpl uses to calculate the current night mode to your base activity code and then override it before the super.onConfigurationChanged call. In Kotlin it would look like this:

private var activityHandlesUiMode = false
private var activityHandlesUiModeChecked = false

private val isActivityManifestHandlingUiMode: Boolean
    get() {
        if (!activityHandlesUiModeChecked) {
            val pm = packageManager ?: return false
            activityHandlesUiMode = try {
                val info = pm.getActivityInfo(ComponentName(this, javaClass), 0)
                info.configChanges and ActivityInfo.CONFIG_UI_MODE != 0
            } catch (e: PackageManager.NameNotFoundException) {
                false
            }
        }
        activityHandlesUiModeChecked = true
        return activityHandlesUiMode
    }

override fun onConfigurationChanged(newConfig: Configuration) {
    if (isActivityManifestHandlingUiMode) {
        val nightMode = if (delegate.localNightMode != AppCompatDelegate.MODE_NIGHT_UNSPECIFIED) 
            delegate.localNightMode
        else
            AppCompatDelegate.getDefaultNightMode()
        val configNightMode = when (nightMode) {
            AppCompatDelegate.MODE_NIGHT_YES -> Configuration.UI_MODE_NIGHT_YES
            AppCompatDelegate.MODE_NIGHT_NO -> Configuration.UI_MODE_NIGHT_NO
            else -> applicationContext.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
        }
        newConfig.uiMode = configNightMode or (newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv())
    }
    super.onConfigurationChanged(newConfig)
}

Finally, I find the problem in my app. When migrating the project to Androidx dependencies of my project changed like this:

dependencies {
    implementation fileTree(include: ['*.jar'], dir: 'libs')
    implementation 'androidx.appcompat:appcompat:1.1.0-alpha03'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    implementation 'com.google.android.material:material:1.1.0-alpha04'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0-alpha02'
} 

As it is seen, version of androidx.appcompat:appcompat is 1.1.0-alpha03 when I changed it to the latest stable version, 1.0.2, my problem is resolved and the change language working properly.

I find the latest stable version of appcompat library in Maven Repository. I also change other libraries to the latest stable version.

Now my app dependencies section is like bellow:

dependencies {
    implementation fileTree(include: ['*.jar'], dir: 'libs')
    implementation 'androidx.appcompat:appcompat:1.0.2'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    implementation 'com.google.android.material:material:1.0.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
}