diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index a1e731bc3..fb1f9640b 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -6,6 +6,11 @@ **Bug Fixes** +* Fix `ActivityScenario.moveToState` and `ActivityScenario.close` throwing a + `NullPointerException` ("Current state was null unexpectedly") when the + activity is finishing but still in a transient lifecycle stage, such as an + activity that finished itself while stopped. + **New Features** **Breaking Changes** diff --git a/core/java/androidx/test/core/app/ActivityScenario.java b/core/java/androidx/test/core/app/ActivityScenario.java index e82c603b1..d61d967f7 100644 --- a/core/java/androidx/test/core/app/ActivityScenario.java +++ b/core/java/androidx/test/core/app/ActivityScenario.java @@ -51,6 +51,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; @@ -654,6 +655,14 @@ public ActivityScenario moveToState(State newState) { getInstrumentation().waitForIdleSync(); ActivityState currentState = getCurrentActivityState(); + if (currentState.state == null && isFinishingOffMainThread(currentState.activity)) { + // An idle main thread does not guarantee that the activity has reached a steady state. A + // finishing activity may stay in a transient stage (e.g. RESTARTED, if it finished itself + // while stopped) until the system destroys it. Since it is going to be destroyed, wait for + // it to settle instead of failing. + waitForActivityToBecomeAnyOf(STEADY_STATES.values().toArray(new State[0])); + currentState = getCurrentActivityState(); + } checkNotNull( currentState.state, String.format("Current state was null unexpectedly. Last stage = %s", currentState.stage)); @@ -690,6 +699,22 @@ public ActivityScenario moveToState(State newState) { return this; } + /** + * Returns true if the given activity is finishing and the caller is not on the main thread. + * + *

Waiting for a lifecycle transition is only meaningful off the main thread, since the + * transition cannot progress while the main thread is blocked (e.g. in Robolectric tests that run + * on the main thread). + */ + private static boolean isFinishingOffMainThread(@Nullable Activity activity) { + if (activity == null || Looper.myLooper() == Looper.getMainLooper()) { + return false; + } + AtomicBoolean isFinishing = new AtomicBoolean(); + getInstrumentation().runOnMainSync(() -> isFinishing.set(activity.isFinishing())); + return isFinishing.get(); + } + /** * Recreates the Activity. * diff --git a/core/javatests/androidx/test/core/app/ActivityScenarioTest.java b/core/javatests/androidx/test/core/app/ActivityScenarioTest.java index df94679c2..619de0de6 100644 --- a/core/javatests/androidx/test/core/app/ActivityScenarioTest.java +++ b/core/javatests/androidx/test/core/app/ActivityScenarioTest.java @@ -21,13 +21,16 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; +import static org.junit.Assume.assumeFalse; import android.app.Activity; import android.app.ActivityOptions; import android.content.Intent; import android.net.Uri; +import android.os.Build; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import androidx.lifecycle.Lifecycle.State; import androidx.test.core.app.ActivityScenario.ActivityAction; import androidx.test.core.app.testing.AsyncRecordingActivity; @@ -40,6 +43,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SdkSuppress; import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.runner.lifecycle.ActivityLifecycleCallback; +import androidx.test.runner.lifecycle.ActivityLifecycleMonitor; import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; import androidx.test.runner.lifecycle.Stage; import java.util.ArrayList; @@ -388,6 +393,66 @@ public void activityResultWithResultDataAfterRecreate() throws Exception { } } + @Test + public void closeAfterActivityFinishedItselfWhileStopped() throws Exception { + // Robolectric does not launch activities started via Activity#startActivity. + assumeFalse(Build.FINGERPRINT.contains("robolectric")); + try (ActivityScenario scenario = + ActivityScenario.launchActivityForResult(RecordingActivity.class)) { + // Cover the activity under test with another activity so that it gets stopped. + scenario.onActivity( + activity -> + activity.startActivity(new Intent(activity, RecreationRecordingActivity.class))); + waitForState(scenario, State.CREATED); + + // Finish the covering activity, and finish the activity under test once the covering activity + // is paused. At that point the system has already started bringing the activity under test + // back to the foreground, so it receives onRestart (but not onStart since it is finishing) + // and stays in the transient RESTARTED stage until it is destroyed, even though the main + // thread is idle. + ActivityLifecycleMonitor monitor = ActivityLifecycleMonitorRegistry.getInstance(); + ActivityLifecycleCallback finishOnCoveringActivityPaused = + new ActivityLifecycleCallback() { + @Override + public void onActivityLifecycleChanged(Activity activity, Stage stage) { + if (activity instanceof RecreationRecordingActivity && stage == Stage.PAUSED) { + // Post so that the system is notified of the pause (and starts resuming the + // activity under test) before the activity under test finishes. + new Handler(Looper.getMainLooper()) + .post( + () -> { + for (Activity stopped : monitor.getActivitiesInStage(Stage.STOPPED)) { + if (stopped instanceof RecordingActivity) { + stopped.setResult(RESULT_OK); + stopped.finish(); + } + } + }); + } + } + }; + monitor.addLifecycleCallback(finishOnCoveringActivityPaused); + try { + InstrumentationRegistry.getInstrumentation() + .runOnMainSync( + () -> { + for (Activity activity : monitor.getActivitiesInStage(Stage.RESUMED)) { + if (activity instanceof RecreationRecordingActivity) { + activity.finish(); + } + } + }); + + assertThat(scenario.getResult().getResultCode()).isEqualTo(RESULT_OK); + // Must not throw even if the activity has not been destroyed yet. + scenario.close(); + } finally { + monitor.removeLifecycleCallback(finishOnCoveringActivityPaused); + } + assertThat(scenario.getState()).isEqualTo(State.DESTROYED); + } + } + @Test public void scenarioResultAfterLaunch() throws Exception { try (ActivityScenario scenario = @@ -552,4 +617,13 @@ public void launchActivityForResult_intentWithAction() { private static Stage lastLifeCycleTransition(Activity activity) { return ActivityLifecycleMonitorRegistry.getInstance().getLifecycleStageOf(activity); } + + private static void waitForState(ActivityScenario scenario, State state) + throws InterruptedException { + long deadline = SystemClock.elapsedRealtime() + TimeUnit.SECONDS.toMillis(10); + while (scenario.getState() != state && SystemClock.elapsedRealtime() < deadline) { + Thread.sleep(50); + } + assertThat(scenario.getState()).isEqualTo(state); + } }