/*
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.test.core.app;
import static androidx.test.InstrumentationRegistry.getInstrumentation;
import static androidx.test.internal.util.Checks.checkArgument;
import static androidx.test.internal.util.Checks.checkNotMainThread;
import static androidx.test.internal.util.Checks.checkNotNull;
import static androidx.test.internal.util.Checks.checkState;
import android.app.Activity;
import android.arch.lifecycle.Lifecycle.State;
import android.content.Intent;
import android.provider.Settings;
import android.support.annotation.GuardedBy;
import android.support.annotation.Nullable;
import androidx.test.annotation.Beta;
import androidx.test.internal.platform.ServiceLoaderWrapper;
import androidx.test.internal.platform.app.ActivityInvoker;
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.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
* ActivityScenario provides APIs to start and drive an Activity's lifecycle state for testing. It
* works with arbitrary activities and works consistently across different versions of the Android
* framework.
*
* <p>The ActivityScenario API uses {@link Lifecycle.State} extensively. If you are unfamiliar with
* {@link android.arch.lifecycle} components, please read <a
* href="https://developer.android.com/topic/libraries/architecture/lifecycle#lc">lifecycle</a>
* before starting. It is crucial to understand the difference between {@link Lifecycle.State} and
* {@link Lifecycle.Event}.
*
* <p>{@link ActivityScenario#moveTo(Lifecycle.State)} allows you to transition your Activity's
* state to {@link State.CREATED}, {@link State.STARTED}, or {@link State.RESUMED}. There are two
* paths for an Activity to reach {@link State.CREATED}: after {@link Event.ON_CREATE} happens but
* before {@link Event.ON_START}, and after {@link Event.ON_STOP}. ActivityScenario always moves the
* Activity's state to the second one. The same applies to {@link State.STARTED}.
*
* <p>This class is a replacement of ActivityController in Robolectric and ActivityTestRule in ATSL.
*
* <p>Following are the example of common use cases.
*
* <pre>
* Before:
* MyActivity activity = Robolectric.setupActivity(MyActivity.class);
* assertThat(activity.getSomething()).isEqualTo("something");
*
* After:
* ActivityScenario<MyActivity> scenario = ActivityScenario.launch(MyActivity.class);
* scenario.onActivity(activity -> {
* assertThat(activity.getSomething()).isEqualTo("something");
* });
*
* Before:
* ActivityController<MyActivity> controller = Robolectric.buildActivity(MyActivity.class);
* controller.create().start().resume();
* controller.get(); // Returns resumed activity.
* controller.pause().get(); // Returns paused activity.
* controller.stop().get(); // Returns stopped activity.
*
* After:
* ActivityScenario<MyActivity> scenario = ActivityScenario.launch(MyActivity.class);
* scenario.onActivity(activity -> {}); // Your activity is resumed.
* scenario.moveTo(State.STARTED);
* scenario.onActivity(activity -> {}); // Your activity is paused.
* scenario.moveTo(State.CREATED);
* scenario.onActivity(activity -> {}); // Your activity is stopped.
* </pre>
*/
@Beta
public final class ActivityScenario<A extends Activity> {
/**
* The timeout for {@link #waitForActivityToBecome} method. If an Activity doesn't become
* requested state after the timeout, we will throw {@link AssertionError} to fail tests.
*/
private static final long TIMEOUT_MILLISECONDS = 45000;
/** An ActivityInvoker to use. Implementation class can be configured by service provider. */
private static final ActivityInvoker activityInvoker;
static {
List<ActivityInvoker> impls = ServiceLoaderWrapper.loadService(ActivityInvoker.class);
if (impls.isEmpty()) {
activityInvoker = new InstrumentationActivityInvoker();
} else if (impls.size() == 1) {
activityInvoker = impls.get(0);
} else {
throw new IllegalStateException(
String.format(
"Found more than one %s implementations.", ActivityInvoker.class.getName()));
}
}
/**
* A map to convert {@link Stage} to {@link State}. This map only contains stages that are
* supported in {@link #moveToState}.
*/
private static final Map<Stage, State> SUPPORTED_STAGE_TO_STATE = new EnumMap<>(Stage.class);
static {
SUPPORTED_STAGE_TO_STATE.put(Stage.RESUMED, State.RESUMED);
SUPPORTED_STAGE_TO_STATE.put(Stage.PAUSED, State.STARTED);
SUPPORTED_STAGE_TO_STATE.put(Stage.STOPPED, State.CREATED);
}
/** A lock that is used to block the main thread until the Activity becomes a requested state. */
private final ReentrantLock lock = new ReentrantLock();
/** A map to retrieve condition object by state. */
private final Map<State, Condition> stateToCondition = new EnumMap<>(State.class);
/** An intent to start a testing Activity. */
private final Intent startActivityIntent;
/**
* A current activity stage. This variable is updated by {@link ActivityLifecycleMonitor} from the
* main thread.
*/
@GuardedBy("lock")
private Stage currentActivityStage;
/**
* A current activity. This variable is updated by {@link ActivityLifecycleMonitor} from the main
* thread.
*/
@GuardedBy("lock")
@Nullable
private A currentActivity;
/** Private constructor. Use {@link #launch} to instantiate this class. */
private ActivityScenario(Class<A> activityClass) {
checkState(
Settings.System.getInt(
getInstrumentation().getTargetContext().getContentResolver(),
Settings.Global.ALWAYS_FINISH_ACTIVITIES,
0)
== 0,
"\"Don't keep activities\" developer options must be disabled for ActivityScenario");
stateToCondition.put(State.CREATED, lock.newCondition());
stateToCondition.put(State.STARTED, lock.newCondition());
stateToCondition.put(State.RESUMED, lock.newCondition());
startActivityIntent = activityInvoker.getIntentForActivity(activityClass);
currentActivityStage = Stage.PRE_ON_CREATE;
}
/**
* Launches an Activity of a given class and constructs ActivityScenario with the activity. Waits
* for the activity to become {@link State#RESUMED}.
*
* <p>This method cannot be called from the main thread except in Robolectric tests.
*
* @throws AssertionError if Activity never becomes {@link State#RESUMED} after timeout
* @return ActivityScenario which you can use to make further state transitions
*/
public static <A extends Activity> ActivityScenario<A> launch(Class<A> activityClass) {
checkNotMainThread();
getInstrumentation().waitForIdleSync();
ActivityScenario<A> scenario = new ActivityScenario<>(activityClass);
ActivityLifecycleMonitorRegistry.getInstance()
.addLifecycleCallback(scenario.activityLifecycleObserver);
activityInvoker.startActivity(scenario.startActivityIntent);
scenario.waitForActivityToBecome(State.RESUMED);
return scenario;
}
private void waitForActivityToBecome(State state) {
// Wait for idle sync otherwise we might hit transient state.
getInstrumentation().waitForIdleSync();
lock.lock();
try {
if (state == SUPPORTED_STAGE_TO_STATE.get(currentActivityStage)) {
return;
}
// Spurious wakeups may happen so we wrap await() with while-loop.
long now = System.currentTimeMillis();
long deadline = now + TIMEOUT_MILLISECONDS;
while (now < deadline && state != SUPPORTED_STAGE_TO_STATE.get(currentActivityStage)) {
stateToCondition.get(state).await(deadline - now, TimeUnit.MILLISECONDS);
now = System.currentTimeMillis();
}
if (state != SUPPORTED_STAGE_TO_STATE.get(currentActivityStage)) {
throw new AssertionError(
String.format(
"Activity never becomes requested state \"%s\" "
+ "(last lifecycle transition = \"%s\")",
state, currentActivityStage));
}
} catch (InterruptedException e) {
throw new AssertionError(
String.format(
"Activity never becomes requested state \"%s\" (last lifecycle transition = \"%s\")",
state, currentActivityStage));
} finally {
lock.unlock();
}
}
/** Observes an Activity lifecycle change events and updates ActivityScenario's internal state. */
private final ActivityLifecycleCallback activityLifecycleObserver =
new ActivityLifecycleCallback() {
@Override
public void onActivityLifecycleChanged(Activity activity, Stage stage) {
if (!startActivityIntent.filterEquals(activity.getIntent())) {
return;
}
lock.lock();
try {
currentActivityStage = stage;
currentActivity = (A) (stage != Stage.DESTROYED ? activity : null);
State currentState = SUPPORTED_STAGE_TO_STATE.get(stage);
if (currentState != null) {
stateToCondition.get(currentState).signal();
}
} finally {
lock.unlock();
}
}
};
/**
* ActivityState is a state class that holds a snapshot of an Activity's current state and a
* reference to the Activity.
*/
private static class ActivityState<A extends Activity> {
@Nullable final A activity;
@Nullable final State state;
ActivityState(@Nullable A activity, @Nullable State state) {
this.activity = activity;
this.state = state;
}
}
private ActivityState<A> getCurrentActivityState() {
getInstrumentation().waitForIdleSync();
lock.lock();
try {
return new ActivityState<>(
currentActivity, SUPPORTED_STAGE_TO_STATE.get(currentActivityStage));
} finally {
lock.unlock();
}
}
/**
* Moves Activity state to a new state.
*
* <p>If a new state and current state are the same, it does nothing. It accepts {@link
* State.CREATED}, {@link State.STARTED}, and {@link State.RESUMED}.
*
* <p>This method cannot be called from the main thread except in Robolectric tests.
*
* @throws IllegalArgumentException if unsupported {@code newState} is given
* @throws IllegalStateException if Activity is destroyed, finished or finishing
* @throws AssertionError if Activity never becomes requested state
*/
public ActivityScenario<A> moveToState(State newState) {
checkNotMainThread();
checkArgument(
stateToCondition.containsKey(newState),
String.format("A requested state \"%s\" is not supported", newState));
getInstrumentation().waitForIdleSync();
ActivityState<A> currentState = getCurrentActivityState();
checkNotNull(currentState.state);
if (currentState.state == newState) {
return this;
}
checkState(
currentState.state != State.DESTROYED && currentState.activity != null,
String.format(
"Cannot move to state \"%s\" since the Activity has been destroyed already", newState));
switch (newState) {
case CREATED:
activityInvoker.stopActivity(currentState.activity);
break;
case STARTED:
moveToState(State.RESUMED);
activityInvoker.pauseActivity(currentState.activity);
break;
case RESUMED:
activityInvoker.resumeActivity(currentState.activity);
break;
default:
throw new IllegalArgumentException(
String.format("A requested state \"%s\" is not supported", newState));
}
waitForActivityToBecome(newState);
return this;
}
/**
* Recreates the Activity.
*
* <p>A current Activity will be destroyed after its data is saved into {@link android.os.Bundle}
* with {@link Activity#savedInstanceState}, then it creates a new Activity with the saved Bundle.
* After this method call, it is ensured that the Activity state goes back to the same state as
* its previous state.
*
* <p>This method cannot be called from the main thread except in Robolectric tests.
*
* @throws IllegalStateException if Activity is destroyed, finished or finishing
* @throws AssertionError if Activity never be re-created
*/
public ActivityScenario<A> recreate() {
checkNotMainThread();
getInstrumentation().waitForIdleSync();
final ActivityState<A> prevActivityState = getCurrentActivityState();
checkNotNull(prevActivityState.activity);
checkNotNull(prevActivityState.state);
// Move the state to RESUMED before starting re-creation and manually move the state to its
// original state after the re-creation. This is because Activity#recreate's behavior differs
// by Android framework version. See InstrumentationActivityInvoker#recreateActivity for
// details.
moveToState(State.RESUMED);
activityInvoker.recreateActivity(prevActivityState.activity);
ActivityState<A> activityState;
long now = System.currentTimeMillis();
long deadline = now + TIMEOUT_MILLISECONDS;
do {
waitForActivityToBecome(State.RESUMED);
now = System.currentTimeMillis();
activityState = getCurrentActivityState();
} while (now < deadline && activityState.activity == prevActivityState.activity);
if (activityState.activity == prevActivityState.activity) {
throw new IllegalStateException("Requested a re-creation of Activity but didn't happen");
}
moveToState(prevActivityState.state);
return this;
}
/**
* ActivityAction interface should be implemented by any class whose instances are intended to be
* executed by the main thread. An Activity that is instrumented by the ActivityScenario is passed
* to {@link ActivityAction#perform} method.
*
* <pre>
* Example:
* ActivityScenario<MyActivity> scenario = ActivityScenario.launch(MyActivity.class);
* scenario.onActivity(activity -> {
* assertThat(activity.getSomething()).isEqualTo("something");
* });
* </pre>
*
* <p>You should never keep the Activity reference. It should only be accessed in {@link
* ActivityAction#perform} scope for two reasons: 1) Android framework may re-create the Activity
* during lifecycle changes, your holding reference might be stale. 2) It increases the reference
* counter and it may affect to the framework behavior, especially after you finish the Activity.
*
* <pre>
* Bad Example:
* ActivityScenario<MyActivity> scenario = ActivityScenario.launch(MyActivity.class);
* final MyActivity[] myActivityHolder = new MyActivity[1];
* scenario.onActivity(activity -> {
* myActivityHolder[0] = activity;
* });
* assertThat(myActivityHolder[0].getSomething()).isEqualTo("something");
* </pre>
*/
public interface ActivityAction<A extends Activity> {
/**
* This method is invoked on the main thread with the reference to the Activity.
*
* @param activity an Activity instrumented by the {@link ActivityScenario}. It never be null.
*/
void perform(A activity);
}
/**
* Runs a given {@code action} on the current Activity's main thread.
*
* <p>Note that you should never keep Activity reference passed into your {@code action} because
* it can be recreated at anytime during state transitions.
*
* <p>Throwing an exception from {@code action} makes the Activity to crash. You can inspect the
* exception in logcat outputs.
*
* <p>This method cannot be called from the main thread except in Robolectric tests.
*
* @throws IllegalStateException if Activity is destroyed, finished or finishing
*/
public ActivityScenario<A> onActivity(final ActivityAction<A> action) {
checkNotMainThread();
getInstrumentation().waitForIdleSync();
getInstrumentation()
.runOnMainSync(
() -> {
lock.lock();
try {
checkNotNull(
currentActivity,
"Cannot run onActivity since Activity has been destroyed already");
action.perform(currentActivity);
} finally {
lock.unlock();
}
});
return this;
}
}