Apply configuration changes to `ActivityScenario` `Activity`s

The `Activity`s created by `ActivityScenario` are created through the `RoboMonitoringInstrumentation`, keep track of all the non-destroyed `ActivityController`s created by the instrumentation and when a configuration change happens to the global configuration (which is applied when calling e.g. `RuntimeEnvironment#setQualifiers`) then apply the configuration to the instrumentation created controllers.

A couple of changes were made to support this:

*   The `ActivityController#changeConfiguration` method was not causing all the lifecycle events to get notified as some of them were not going through instrumentation, update to make this method consistent with the relevant lifecycle methods on the controller, this is necessary for `ActivityScenario` to correctly handle the configuration change
*   Add a method to the `ShadowResources` to allow listening for a configuration change, this allows the `RoboMonitoringInstrumentation` to listen for the global configuration change (as its in a different package the `RuntimeEnvironment` cannot directly interact with it)

Note that a call to `visible()` is made in the `RoboMonitoringInstrumentation`, this should really happen in the `ActivityController`, but this is hard to retroactively change, at least make the new `ActivityScenario` case work correctly from start (without this call the view of the recreated activity does not get added to the window and so cannot properly be interacted with).

PiperOrigin-RevId: 468069979
diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/ActivityScenarioTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/ActivityScenarioTest.java
index bd1454e..61f827e 100644
--- a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/ActivityScenarioTest.java
+++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/ActivityScenarioTest.java
@@ -1,10 +1,13 @@
 package org.robolectric.integrationtests.axt;
 
+import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2;
 import static com.google.common.truth.Truth.assertThat;
 
 import android.app.Activity;
+import android.app.UiAutomation;
 import android.content.Context;
 import android.content.Intent;
+import android.content.res.Configuration;
 import android.os.Bundle;
 import androidx.appcompat.R;
 import androidx.appcompat.app.AppCompatActivity;
@@ -13,11 +16,14 @@
 import androidx.test.core.app.ActivityScenario;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
 
 /**
  * Integration tests for {@link ActivityScenario} that verify it behaves consistently on device and
@@ -213,4 +219,23 @@
             assertThat(activity.getSupportFragmentManager().findFragmentById(android.R.id.content))
                 .isNotSameInstanceAs(fragment));
   }
+
+  @Config(minSdk = JELLY_BEAN_MR2)
+  @Test
+  public void setRotation_recreatesActivity() {
+    UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+    try (ActivityScenario<?> scenario = ActivityScenario.launch(TranscriptActivity.class)) {
+      AtomicReference<Activity> originalActivity = new AtomicReference<>();
+      scenario.onActivity(originalActivity::set);
+
+      uiAutomation.setRotation(UiAutomation.ROTATION_FREEZE_90);
+
+      scenario.onActivity(
+          activity -> {
+            assertThat(activity.getResources().getConfiguration().orientation)
+                .isEqualTo(Configuration.ORIENTATION_LANDSCAPE);
+            assertThat(activity).isNotSameInstanceAs(originalActivity);
+          });
+    }
+  }
 }
diff --git a/robolectric/src/main/java/org/robolectric/android/internal/AndroidTestEnvironment.java b/robolectric/src/main/java/org/robolectric/android/internal/AndroidTestEnvironment.java
index b1803bb..36f9eb9 100755
--- a/robolectric/src/main/java/org/robolectric/android/internal/AndroidTestEnvironment.java
+++ b/robolectric/src/main/java/org/robolectric/android/internal/AndroidTestEnvironment.java
@@ -351,7 +351,9 @@
       }
 
       PerfStatsCollector.getInstance()
-          .measure("application onCreate()", () -> application.onCreate());
+          .measure(
+              "application onCreate()",
+              () -> androidInstrumentation.callApplicationOnCreate(application));
     }
 
     return application;
diff --git a/robolectric/src/main/java/org/robolectric/android/internal/RoboMonitoringInstrumentation.java b/robolectric/src/main/java/org/robolectric/android/internal/RoboMonitoringInstrumentation.java
index a5d0656..289d9c0 100644
--- a/robolectric/src/main/java/org/robolectric/android/internal/RoboMonitoringInstrumentation.java
+++ b/robolectric/src/main/java/org/robolectric/android/internal/RoboMonitoringInstrumentation.java
@@ -1,5 +1,6 @@
 package org.robolectric.android.internal;
 
+import static org.robolectric.Shadows.shadowOf;
 import static org.robolectric.shadow.api.Shadow.extract;
 import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
 
@@ -9,11 +10,14 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.pm.ActivityInfo;
+import android.content.res.Configuration;
+import android.content.res.Resources;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.IBinder;
 import android.os.Looper;
 import android.os.UserHandle;
+import android.util.DisplayMetrics;
 import android.util.Log;
 import androidx.test.internal.runner.intent.IntentMonitorImpl;
 import androidx.test.internal.runner.lifecycle.ActivityLifecycleMonitorImpl;
@@ -25,6 +29,8 @@
 import androidx.test.runner.lifecycle.ApplicationLifecycleMonitorRegistry;
 import androidx.test.runner.lifecycle.ApplicationStage;
 import androidx.test.runner.lifecycle.Stage;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import javax.annotation.Nullable;
@@ -47,6 +53,7 @@
   private final ApplicationLifecycleMonitorImpl applicationMonitor =
       new ApplicationLifecycleMonitorImpl();
   private final IntentMonitorImpl intentMonitor = new IntentMonitorImpl();
+  private final List<ActivityController<?>> createdActivities = new ArrayList<>();
 
   /**
    * Sets up lifecycle monitoring, and argument registry.
@@ -60,6 +67,7 @@
     ActivityLifecycleMonitorRegistry.registerInstance(lifecycleMonitor);
     ApplicationLifecycleMonitorRegistry.registerInstance(applicationMonitor);
     IntentMonitorRegistry.registerInstance(intentMonitor);
+    shadowOf(Resources.getSystem()).addConfigurationChangeListener(this::updateConfiguration);
 
     super.onCreate(arguments);
   }
@@ -99,6 +107,7 @@
     if (controller.get().isFinishing()) {
       controller.destroy();
     } else {
+      createdActivities.add(controller);
       controller.start()
           .postCreate(null)
           .resume()
@@ -263,6 +272,9 @@
 
   @Override
   public void callActivityOnDestroy(Activity activity) {
+    if (activity.isFinishing()) {
+      createdActivities.removeIf(controller -> controller.get() == activity);
+    }
     super.callActivityOnDestroy(activity);
     lifecycleMonitor.signalLifecycleChange(Stage.DESTROYED, activity);
   }
@@ -316,4 +328,22 @@
   public Context getContext() {
     return RuntimeEnvironment.getApplication();
   }
+
+  private void updateConfiguration(
+      Configuration oldConfig, Configuration newConfig, DisplayMetrics newMetrics) {
+    int changedConfig = oldConfig.diff(newConfig);
+    List<ActivityController<?>> controllers = new ArrayList<>(createdActivities);
+    for (ActivityController<?> controller : controllers) {
+      if (createdActivities.contains(controller)) {
+        Activity activity = controller.get();
+        controller.configurationChange(newConfig, newMetrics, changedConfig);
+        // If the activity is recreated then make the new activity visible, this should be done by
+        // configurationChange but there's a pre-existing TODO to address this and it will require
+        // more work to make it function correctly.
+        if (controller.get() != activity) {
+          controller.visible();
+        }
+      }
+    }
+  }
 }
diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowResourcesTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowResourcesTest.java
index 54dfdff..51e7cdf 100644
--- a/robolectric/src/test/java/org/robolectric/shadows/ShadowResourcesTest.java
+++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowResourcesTest.java
@@ -2,9 +2,11 @@
 
 import static android.os.Build.VERSION_CODES.N_MR1;
 import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
 import static org.robolectric.shadows.ShadowAssetManager.useLegacy;
 
 import android.content.res.AssetFileDescriptor;
+import android.content.res.Configuration;
 import android.content.res.Resources;
 import android.content.res.TypedArray;
 import android.content.res.XmlResourceParser;
@@ -17,6 +19,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import com.google.common.collect.Range;
 import java.io.InputStream;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -60,10 +63,12 @@
     int identifier_missing_from_r_file =
         resources.getIdentifier("secondary_text_material_dark", "color", "android");
 
-    // We expect Robolectric to generate a placeholder identifier where one was not generated in the android R files.
+    // We expect Robolectric to generate a placeholder identifier where one was not generated in the
+    // android R files.
     assertThat(identifier_missing_from_r_file).isNotEqualTo(0);
 
-    // We expect to be able to successfully android:color/secondary_text_material_dark to a ColorStateList.
+    // We expect to be able to successfully android:color/secondary_text_material_dark to a
+    // ColorStateList.
     assertThat(resources.getColorStateList(identifier_missing_from_r_file)).isNotNull();
   }
 
@@ -97,7 +102,8 @@
 
     theme.resolveAttribute(android.R.attr.windowBackground, out, true);
     assertThat(out.type).isNotEqualTo(TypedValue.TYPE_REFERENCE);
-    assertThat(out.type).isIn(Range.closed(TypedValue.TYPE_FIRST_COLOR_INT, TypedValue.TYPE_LAST_COLOR_INT));
+    assertThat(out.type)
+        .isIn(Range.closed(TypedValue.TYPE_FIRST_COLOR_INT, TypedValue.TYPE_LAST_COLOR_INT));
 
     int value = resources.getColor(android.R.color.black);
     assertThat(out.data).isEqualTo(value);
@@ -118,15 +124,17 @@
   @Test
   public void obtainStyledAttributes_shouldCheckXmlFirst_fromAttributeSetBuilder() {
 
-    // This simulates a ResourceProvider built from a 21+ SDK as viewportHeight / viewportWidth were introduced in API 21
-    // but the public ID values they are assigned clash with private com.android.internal.R values on older SDKs. This
-    // test ensures that even on older SDKs, on calls to obtainStyledAttributes() Robolectric will first check for matching
-    // resource ID values in the AttributeSet before checking the theme.
+    // This simulates a ResourceProvider built from a 21+ SDK as viewportHeight / viewportWidth were
+    // introduced in API 21 but the public ID values they are assigned clash with private
+    // com.android.internal.R values on older SDKs. This test ensures that even on older SDKs, on
+    // calls to obtainStyledAttributes() Robolectric will first check for matching resource ID
+    // values in the AttributeSet before checking the theme.
 
-    AttributeSet attributes = Robolectric.buildAttributeSet()
-        .addAttribute(android.R.attr.viewportWidth, "12.0")
-        .addAttribute(android.R.attr.viewportHeight, "24.0")
-        .build();
+    AttributeSet attributes =
+        Robolectric.buildAttributeSet()
+            .addAttribute(android.R.attr.viewportWidth, "12.0")
+            .addAttribute(android.R.attr.viewportHeight, "24.0")
+            .build();
 
     TypedArray typedArray =
         ApplicationProvider.getApplicationContext()
@@ -150,8 +158,7 @@
         (XmlResourceParserImpl) resources.getXml(R.xml.preferences);
     assertThat(xmlResourceParser.qualify("?ref")).isEqualTo("?org.robolectric:attr/ref");
 
-    xmlResourceParser =
-        (XmlResourceParserImpl) resources.getXml(android.R.layout.list_content);
+    xmlResourceParser = (XmlResourceParserImpl) resources.getXml(android.R.layout.list_content);
     assertThat(xmlResourceParser.qualify("?ref")).isEqualTo("?android:attr/ref");
   }
 
@@ -232,4 +239,54 @@
 
     assertThat(sourceRedId).isEqualTo(R.xml.preferences);
   }
+
+  @Test
+  public void addConfigurationChangeListener_callsOnConfigurationChange() {
+    AtomicBoolean listenerWasCalled = new AtomicBoolean();
+    shadowOf(resources)
+        .addConfigurationChangeListener(
+            (oldConfig, newConfig, newMetrics) -> {
+              listenerWasCalled.set(true);
+              assertThat(newConfig.fontScale).isEqualTo(oldConfig.fontScale * 2);
+            });
+
+    Configuration newConfig = new Configuration(resources.getConfiguration());
+    newConfig.fontScale *= 2;
+    resources.updateConfiguration(newConfig, resources.getDisplayMetrics());
+
+    assertThat(listenerWasCalled.get()).isTrue();
+  }
+
+  @Test
+  public void removeConfigurationChangeListener_doesNotCallOnConfigurationChange() {
+    AtomicBoolean listenerWasCalled = new AtomicBoolean();
+    ShadowResources.OnConfigurationChangeListener listener =
+        (oldConfig, newConfig, newMetrics) -> listenerWasCalled.set(true);
+    Configuration newConfig = new Configuration(resources.getConfiguration());
+    newConfig.fontScale *= 2;
+
+    shadowOf(resources).addConfigurationChangeListener(listener);
+    shadowOf(resources).removeConfigurationChangeListener(listener);
+    resources.updateConfiguration(newConfig, resources.getDisplayMetrics());
+
+    assertThat(listenerWasCalled.get()).isFalse();
+  }
+
+  @Test
+  public void subclassWithNpeGetConfiguration_constructsCorrectly() {
+    // Simulate the behavior of ResourcesWrapper during construction which will throw an NPE if
+    // getConfiguration is called, on lower SDKs the Configuration constructor calls
+    // updateConfiguration(), the ShadowResources will attempt to call getConfiguration during this
+    // method call and shouldn't fail.
+    Resources resourcesSubclass =
+        new Resources(
+            resources.getAssets(), resources.getDisplayMetrics(), resources.getConfiguration()) {
+          @Override
+          public Configuration getConfiguration() {
+            throw new NullPointerException();
+          }
+        };
+
+    assertThat(resourcesSubclass).isNotNull();
+  }
 }
diff --git a/shadows/framework/src/main/java/org/robolectric/RuntimeEnvironment.java b/shadows/framework/src/main/java/org/robolectric/RuntimeEnvironment.java
index 2adbaeb..f600bfe 100755
--- a/shadows/framework/src/main/java/org/robolectric/RuntimeEnvironment.java
+++ b/shadows/framework/src/main/java/org/robolectric/RuntimeEnvironment.java
@@ -200,7 +200,11 @@
       configuration = new Configuration();
     }
     Bootstrap.applyQualifiers(newQualifiers, getApiLevel(), configuration, displayMetrics);
+    if (Boolean.getBoolean("robolectric.nativeruntime.enableGraphics")) {
+      Bitmap.setDefaultDensity(displayMetrics.densityDpi);
+    }
 
+    // Update the resources last so that listeners will have a consistent environment.
     Resources systemResources = Resources.getSystem();
     systemResources.updateConfiguration(configuration, displayMetrics);
     if (RuntimeEnvironment.application != null) {
@@ -210,9 +214,6 @@
       // changes will be propagated once the application is finally loaded
       Bootstrap.updateDisplayResources(configuration, displayMetrics);
     }
-    if (Boolean.getBoolean("robolectric.nativeruntime.enableGraphics")) {
-      Bitmap.setDefaultDensity(displayMetrics.densityDpi);
-    }
   }
 
 
diff --git a/shadows/framework/src/main/java/org/robolectric/android/controller/ActivityController.java b/shadows/framework/src/main/java/org/robolectric/android/controller/ActivityController.java
index 7d8b226..cbc8aa2 100644
--- a/shadows/framework/src/main/java/org/robolectric/android/controller/ActivityController.java
+++ b/shadows/framework/src/main/java/org/robolectric/android/controller/ActivityController.java
@@ -15,9 +15,12 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.pm.ActivityInfo;
+import android.content.pm.ActivityInfo.Config;
 import android.content.pm.PackageManager;
 import android.content.res.Configuration;
+import android.content.res.Resources;
 import android.os.Bundle;
+import android.util.DisplayMetrics;
 import android.view.Display;
 import android.view.ViewRootImpl;
 import android.view.WindowManager;
@@ -99,11 +102,8 @@
     ComponentName componentName =
         new ComponentName(context.getPackageName(), this.component.getClass().getName());
     ((ShadowPackageManager) extract(packageManager)).addActivityIfNotPresent(componentName);
-    packageManager
-        .setComponentEnabledSetting(
-            componentName,
-            PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
-            0);
+    packageManager.setComponentEnabledSetting(
+        componentName, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 0);
     ShadowActivity shadowActivity = Shadow.extract(component);
     shadowActivity.callAttach(getIntent(), activityOptions, lastNonConfigurationInstances);
     shadowActivity.attachController(this);
@@ -131,7 +131,8 @@
     return this;
   }
 
-  @Override public ActivityController<T> create() {
+  @Override
+  public ActivityController<T> create() {
     return create(null);
   }
 
@@ -305,7 +306,8 @@
   }
 
   /**
-   * Calls the same lifecycle methods on the Activity called by Android the first time the Activity is created.
+   * Calls the same lifecycle methods on the Activity called by Android the first time the Activity
+   * is created.
    *
    * @return Activity controller instance.
    */
@@ -335,23 +337,39 @@
   }
 
   /**
-   * Applies the current system configuration to the Activity.
+   * Performs a configuration change on the Activity. See {@link #configurationChange(Configuration,
+   * DisplayMetrics, int)}. The configuration is taken from the application's configuration.
    *
-   * <p>This can be used in conjunction with {@link RuntimeEnvironment#setQualifiers(String)} to
-   * simulate configuration changes.
-   *
-   * <p>If the activity is configured to handle changes without being recreated, {@link
-   * Activity#onConfigurationChanged(Configuration)} will be called. Otherwise, the activity is
-   * recreated as described <a
-   * href="https://developer.android.com/guide/topics/resources/runtime-changes.html">here</a>.
-   *
-   * @return ActivityController instance
+   * <p>Generally this method should be avoided due to the way Robolectric shares the application
+   * context with activitys by default, this will result in the configuration diff producing no
+   * indicated change and Robolectric will not recreate the activity. Instead prefer to use {@link
+   * #configurationChange(Configuration, DisplayMetrics, int)} and provide an explicit configuration
+   * and diff.
    */
   public ActivityController<T> configurationChange() {
     return configurationChange(component.getApplicationContext().getResources().getConfiguration());
   }
 
   /**
+   * Performs a configuration change on the Activity. See {@link #configurationChange(Configuration,
+   * DisplayMetrics, int)}. The changed configuration is calculated based on the activity's existing
+   * configuration.
+   *
+   * <p>When using {@link RuntimeEnvironment#setQualifiers(String)} prefer to use the {@link
+   * #configurationChange(Configuration, DisplayMetrics, int)} method and calculate the
+   * configuration diff manually, due to the way Robolectric uses the application context for
+   * activitys by default the configuration diff will otherwise be incorrectly calculated and the
+   * activity will not get recreqted if it doesn't handle configuration change.
+   */
+  public ActivityController<T> configurationChange(final Configuration newConfiguration) {
+    Resources resources = component.getResources();
+    return configurationChange(
+        newConfiguration,
+        resources.getDisplayMetrics(),
+        resources.getConfiguration().diff(newConfiguration));
+  }
+
+  /**
    * Performs a configuration change on the Activity.
    *
    * <p>If the activity is configured to handle changes without being recreated, {@link
@@ -359,18 +377,39 @@
    * recreated as described <a
    * href="https://developer.android.com/guide/topics/resources/runtime-changes.html">here</a>.
    *
+   * <p>Typically configuration should be applied using {@link RuntimeEnvironment#setQualifiers} and
+   * then propagated to the activity controller, e.g.
+   *
+   * <pre>{@code
+   * Resources resources = RuntimeEnvironment.getApplication().getResources();
+   * Configuration oldConfig = new Configuration(resources.getConfiguration());
+   * RuntimeEnvironment.setQualifiers("+ar-rXB");
+   * Configuration newConfig = resources.getConfiguration();
+   * activityController.configurationChange(
+   *     newConfig, resources.getDisplayMetrics(), oldConfig.diff(newConfig));
+   * }</pre>
+   *
    * @param newConfiguration The new configuration to be set.
+   * @param changedConfig The changed configuration properties bitmask (e.g. the result of calling
+   *     {@link Configuration#diff(Configuration)}). This will be used to determine whether the
+   *     activity handles the configuration change or not, and whether it must be recreated.
    * @return ActivityController instance
    */
-  public ActivityController<T> configurationChange(final Configuration newConfiguration) {
-    final Configuration currentConfig = component.getResources().getConfiguration();
-    final int changedBits = currentConfig.diff(newConfiguration);
-    currentConfig.setTo(newConfiguration);
+  // TODO: Passing in the changed config explicitly should be unnecessary (i.e. the controller
+  //  should be able to diff against the current activity configuration), but due to the way
+  //  Robolectric uses the application context as the default activity context the application
+  //  context may be updated before entering this method (e.g. if RuntimeEnvironment#setQualifiers
+  //  was called before calling this method). When this issue is fixed this method should be
+  //  deprecated and removed.
+  public ActivityController<T> configurationChange(
+      Configuration newConfiguration, DisplayMetrics newMetrics, @Config int changedConfig) {
+    component.getResources().updateConfiguration(newConfiguration, newMetrics);
 
-    // TODO: throw on changedBits == 0 since it non-intuitively calls onConfigurationChanged
+    // TODO: throw on changedConfig == 0 since it non-intuitively calls onConfigurationChanged
 
     // Can the activity handle itself ALL configuration changes?
-    if ((getActivityInfo(component.getApplication()).configChanges & changedBits) == changedBits) {
+    if ((getActivityInfo(component.getApplication()).configChanges & changedConfig)
+        == changedConfig) {
       shadowMainLooper.runPaused(
           () -> {
             component.onConfigurationChanged(newConfiguration);
@@ -398,7 +437,7 @@
           () -> {
             // Set flags
             _component_.setChangingConfigurations(true);
-            _component_.setConfigChangeFlags(changedBits);
+            _component_.setConfigChangeFlags(changedConfig);
 
             // Perform activity destruction
             final Bundle outState = new Bundle();
@@ -409,7 +448,7 @@
             // See
             // https://developer.android.com/reference/android/app/Activity#onSaveInstanceState(android.os.Bundle) for documentation explained.
             // And see ActivityThread#callActivityOnStop for related code.
-            _component_.performPause();
+            getInstrumentation().callActivityOnPause(component);
             if (RuntimeEnvironment.getApiLevel() < P) {
               _component_.performSaveInstanceState(outState);
               if (RuntimeEnvironment.getApiLevel() <= M) {
@@ -432,7 +471,7 @@
                     ? null // No framework or user state.
                     : reflector(_NonConfigurationInstances_.class, nonConfigInstance).getActivity();
 
-            _component_.performDestroy();
+            getInstrumentation().callActivityOnDestroy(component);
             makeActivityEligibleForGc();
 
             // Restore theme in case it was set in the test manually.
@@ -446,6 +485,16 @@
             component = recreatedActivity;
             _component_ = _recreatedActivity_;
 
+            // TODO: Because robolectric is currently not creating unique context objects per
+            //  activity and that the app copmat framework uses weak maps to cache resources per
+            //  context the caches end up with stale objects between activity creations (which would
+            //  typically be flushed by an onConfigurationChanged when running in real android). To
+            //  workaround this we can invoke a gc after running the configuration change and
+            //  destroying the old activity which will flush the object references from the weak
+            //  maps (the side effect otherwise is flaky tests that behave differently based on when
+            //  garbage collection last happened to run).
+            System.gc();
+
             // TODO: Pass nonConfigurationInstance here instead of setting
             // mLastNonConfigurationInstances directly below. This field must be set before
             // attach. Since current implementation sets it after attach(), initialization is not
@@ -462,7 +511,7 @@
             shadowActivity.setLastNonConfigurationInstance(activityConfigInstance);
 
             // Create lifecycle
-            _recreatedActivity_.performCreate(outState);
+            getInstrumentation().callActivityOnCreate(recreatedActivity, outState);
 
             if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
 
@@ -472,7 +521,7 @@
               _recreatedActivity_.performStart("configurationChange");
             }
 
-            _recreatedActivity_.performRestoreInstanceState(outState);
+            getInstrumentation().callActivityOnRestoreInstanceState(recreatedActivity, outState);
             _recreatedActivity_.onPostCreate(outState);
             if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
               _recreatedActivity_.performResume();
@@ -607,6 +656,4 @@
     @Accessor("activity")
     Object getActivity();
   }
-
 }
-
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResources.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResources.java
index dc86889..e6120d7 100644
--- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResources.java
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowResources.java
@@ -11,6 +11,7 @@
 
 import android.content.res.AssetFileDescriptor;
 import android.content.res.AssetManager;
+import android.content.res.CompatibilityInfo;
 import android.content.res.Configuration;
 import android.content.res.Resources;
 import android.content.res.Resources.NotFoundException;
@@ -29,8 +30,10 @@
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 import org.robolectric.RuntimeEnvironment;
 import org.robolectric.android.Bootstrap;
 import org.robolectric.annotation.HiddenApi;
@@ -59,6 +62,7 @@
   private static List<LongSparseArray<?>> resettableArrays;
 
   @RealObject Resources realResources;
+  private final Set<OnConfigurationChangeListener> configurationChangeListeners = new HashSet<>();
 
   @Resetter
   public static void reset() {
@@ -292,6 +296,53 @@
     }
   }
 
+  /**
+   * Listener callback that's called when the configuration is updated for a resources. The callback
+   * receives the old and new configs (and can use {@link Configuration#diff(Configuration)} to
+   * produce a diff). The callback is called after the configuration has been applied to the
+   * underlying resources, so obtaining resources will use the new configuration in the callback.
+   */
+  public interface OnConfigurationChangeListener {
+    void onConfigurationChange(
+        Configuration oldConfig, Configuration newConfig, DisplayMetrics newMetrics);
+  }
+
+  /**
+   * Add a listener to observe resource configuration changes. See {@link
+   * OnConfigurationChangeListener}.
+   */
+  public void addConfigurationChangeListener(OnConfigurationChangeListener listener) {
+    configurationChangeListeners.add(listener);
+  }
+
+  /**
+   * Remove a listener to observe resource configuration changes. See {@link
+   * OnConfigurationChangeListener}.
+   */
+  public void removeConfigurationChangeListener(OnConfigurationChangeListener listener) {
+    configurationChangeListeners.remove(listener);
+  }
+
+  @Implementation
+  protected void updateConfiguration(
+      Configuration config, DisplayMetrics metrics, CompatibilityInfo compat) {
+    Configuration oldConfig;
+    try {
+      oldConfig = new Configuration(realResources.getConfiguration());
+    } catch (NullPointerException e) {
+      // In old versions of Android the resource constructor calls updateConfiguration, in the
+      // app compat ResourcesWrapper subclass the reference to the underlying resources hasn't been
+      // configured yet, so it'll throw an NPE, catch this to avoid crashing.
+      oldConfig = null;
+    }
+    reflector(ResourcesReflector.class, realResources).updateConfiguration(config, metrics, compat);
+    if (oldConfig != null && config != null) {
+      for (OnConfigurationChangeListener listener : configurationChangeListeners) {
+        listener.onConfigurationChange(oldConfig, config, metrics);
+      }
+    }
+  }
+
   /** Base class for shadows of {@link Resources.Theme}. */
   public abstract static class ShadowTheme {
 
@@ -426,5 +477,9 @@
 
     @Direct
     int getAttributeSetSourceResId(AttributeSet attrs);
+
+    @Direct
+    void updateConfiguration(
+        Configuration config, DisplayMetrics metrics, CompatibilityInfo compat);
   }
 }