From 1cedde410d74de2d1b997c94084c1fd3b5792780 Mon Sep 17 00:00:00 2001 From: AndroidX Test Team Date: Tue, 20 Oct 2026 07:21:17 -0700 Subject: [PATCH] Fix NullPointerException in ActivityScenario.close() for finishing activities in a transient lifecycle stage. ActivityScenario.moveToState() (and therefore close()) assumed that once the main thread is idle the activity is in a steady lifecycle state. That is not always true: lifecycle transitions are driven by the system, and the main thread can be idle while a transition is still pending. For example, an activity that finishes itself while stopped can receive onRestart() but not onStart(), and then stay in the RESTARTED stage until the system destroys it. A common way to hit this is launchActivityForResult() + getResult() followed by close(), since getResult() returns as soon as the result is delivered, before the activity is destroyed. In that window moveToState() failed with "NullPointerException: Current state was null unexpectedly. Last stage = RESTARTED". When the activity is in a transient stage but is finishing (so it is guaranteed to be destroyed), moveToState() now waits for it to settle instead of failing. The wait is skipped on the main thread (e.g. Robolectric), where the transition cannot progress, and for activities that are not finishing, which keeps the previous behavior for those cases. PiperOrigin-RevId: 1003085587 --- core/CHANGELOG.md | 5 ++ .../test/core/app/ActivityScenario.java | 25 +++++++ .../test/core/app/ActivityScenarioTest.java | 74 +++++++++++++++++++ 3 files changed, 104 insertions(+) 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); + } }