Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
25 changes: 25 additions & 0 deletions core/java/androidx/test/core/app/ActivityScenario.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -654,6 +655,14 @@ public ActivityScenario<A> moveToState(State newState) {
getInstrumentation().waitForIdleSync();

ActivityState<A> 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));
Expand Down Expand Up @@ -690,6 +699,22 @@ public ActivityScenario<A> moveToState(State newState) {
return this;
}

/**
* Returns true if the given activity is finishing and the caller is not on the main thread.
*
* <p>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.
*
Expand Down
74 changes: 74 additions & 0 deletions core/javatests/androidx/test/core/app/ActivityScenarioTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<RecordingActivity> 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<RecreationRecordingActivity> scenario =
Expand Down Expand Up @@ -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);
}
}
Loading