blob: cbc8aa2eea31a868f477e771608680870e12ffc9 [file] [log] [blame]
package org.robolectric.android.controller;
import static android.os.Build.VERSION_CODES.M;
import static android.os.Build.VERSION_CODES.N_MR1;
import static android.os.Build.VERSION_CODES.O_MR1;
import static android.os.Build.VERSION_CODES.P;
import static com.google.common.base.Preconditions.checkNotNull;
import static org.robolectric.shadow.api.Shadow.extract;
import static org.robolectric.util.reflector.Reflector.reflector;
import android.app.Activity;
import android.app.Application;
import android.app.Instrumentation;
import android.content.ComponentName;
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;
import javax.annotation.Nullable;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.shadow.api.Shadow;
import org.robolectric.shadows.ShadowActivity;
import org.robolectric.shadows.ShadowContextThemeWrapper;
import org.robolectric.shadows.ShadowPackageManager;
import org.robolectric.shadows.ShadowViewRootImpl;
import org.robolectric.shadows._Activity_;
import org.robolectric.util.ReflectionHelpers;
import org.robolectric.util.ReflectionHelpers.ClassParameter;
import org.robolectric.util.reflector.Accessor;
import org.robolectric.util.reflector.ForType;
import org.robolectric.util.reflector.WithType;
/**
* ActivityController provides low-level APIs to control activity's lifecycle.
*
* <p>Using ActivityController directly from your tests is strongly discouraged. You have to call
* all the lifecycle callback methods (create, postCreate, start, ...) in the same manner as the
* Android framework by yourself otherwise you'll see fidelity issues. Consider using {@link
* androidx.test.core.app.ActivityScenario} instead, which provides higher-level, streamlined APIs
* to control the lifecycle and it works with instrumentation tests too.
*
* @param <T> a class of the activity which is under control by this class.
*/
@SuppressWarnings("NewApi")
public class ActivityController<T extends Activity>
extends ComponentController<ActivityController<T>, T> implements AutoCloseable {
enum LifecycleState {
INITIAL,
CREATED,
RESTARTED,
STARTED,
RESUMED,
PAUSED,
STOPPED,
DESTROYED
}
private _Activity_ _component_;
private LifecycleState currentState = LifecycleState.INITIAL;
public static <T extends Activity> ActivityController<T> of(
T activity, Intent intent, @Nullable Bundle activityOptions) {
return new ActivityController<>(activity, intent)
.attach(activityOptions, /* lastNonConfigurationInstances= */ null);
}
public static <T extends Activity> ActivityController<T> of(T activity, Intent intent) {
return new ActivityController<>(activity, intent)
.attach(/* activityOptions= */ null, /* lastNonConfigurationInstances= */ null);
}
public static <T extends Activity> ActivityController<T> of(T activity) {
return new ActivityController<>(activity, null)
.attach(/* activityOptions= */ null, /* lastNonConfigurationInstances= */ null);
}
private ActivityController(T activity, Intent intent) {
super(activity, intent);
_component_ = reflector(_Activity_.class, component);
}
private ActivityController<T> attach(
@Nullable Bundle activityOptions,
@Nullable @WithType("android.app.Activity$NonConfigurationInstances")
Object lastNonConfigurationInstances) {
if (attached) {
return this;
}
// make sure the component is enabled
Context context = RuntimeEnvironment.getApplication().getBaseContext();
PackageManager packageManager = context.getPackageManager();
ComponentName componentName =
new ComponentName(context.getPackageName(), this.component.getClass().getName());
((ShadowPackageManager) extract(packageManager)).addActivityIfNotPresent(componentName);
packageManager.setComponentEnabledSetting(
componentName, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 0);
ShadowActivity shadowActivity = Shadow.extract(component);
shadowActivity.callAttach(getIntent(), activityOptions, lastNonConfigurationInstances);
shadowActivity.attachController(this);
attached = true;
return this;
}
private ActivityInfo getActivityInfo(Application application) {
PackageManager packageManager = application.getPackageManager();
ComponentName componentName =
new ComponentName(application.getPackageName(), this.component.getClass().getName());
try {
return packageManager.getActivityInfo(componentName, PackageManager.GET_META_DATA);
} catch (PackageManager.NameNotFoundException e) {
throw new RuntimeException(e);
}
}
public ActivityController<T> create(@Nullable final Bundle bundle) {
shadowMainLooper.runPaused(
() -> {
getInstrumentation().callActivityOnCreate(component, bundle);
currentState = LifecycleState.CREATED;
});
return this;
}
@Override
public ActivityController<T> create() {
return create(null);
}
public ActivityController<T> restart() {
invokeWhilePaused(
() -> {
if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
_component_.performRestart();
} else {
_component_.performRestart(true, "restart()");
}
currentState = LifecycleState.RESTARTED;
});
return this;
}
public ActivityController<T> start() {
// Start and stop are tricky cases. Unlike other lifecycle methods such as
// Instrumentation#callActivityOnPause calls Activity#performPause, Activity#performStop calls
// Instrumentation#callActivityOnStop internally so the dependency direction is the opposite.
invokeWhilePaused(
() -> {
if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
_component_.performStart();
} else {
_component_.performStart("start()");
}
currentState = LifecycleState.STARTED;
});
return this;
}
public ActivityController<T> restoreInstanceState(Bundle bundle) {
shadowMainLooper.runPaused(
() -> getInstrumentation().callActivityOnRestoreInstanceState(component, bundle));
return this;
}
public ActivityController<T> postCreate(@Nullable Bundle bundle) {
invokeWhilePaused(() -> _component_.onPostCreate(bundle));
return this;
}
public ActivityController<T> resume() {
invokeWhilePaused(
() -> {
if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
_component_.performResume();
} else {
_component_.performResume(true, "resume()");
}
currentState = LifecycleState.RESUMED;
});
return this;
}
public ActivityController<T> postResume() {
invokeWhilePaused(() -> _component_.onPostResume());
return this;
}
public ActivityController<T> visible() {
shadowMainLooper.runPaused(
() -> {
// emulate logic of ActivityThread#handleResumeActivity
component.getWindow().getAttributes().type =
WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
_component_.setDecor(component.getWindow().getDecorView());
_component_.makeVisible();
});
shadowMainLooper.idleIfPaused();
ViewRootImpl root = getViewRoot();
// root can be null if activity does not have content attached, or if looper is paused.
// this is unusual but leave the check here for legacy compatibility
if (root != null) {
callDispatchResized(root);
shadowMainLooper.idleIfPaused();
}
return this;
}
private ViewRootImpl getViewRoot() {
return component.getWindow().getDecorView().getViewRootImpl();
}
private void callDispatchResized(ViewRootImpl root) {
((ShadowViewRootImpl) extract(root)).callDispatchResized();
}
public ActivityController<T> windowFocusChanged(boolean hasFocus) {
ViewRootImpl root = getViewRoot();
if (root == null) {
// root can be null if looper was paused during visible. Flush the looper and try again
shadowMainLooper.idle();
root = checkNotNull(getViewRoot());
callDispatchResized(root);
}
((ShadowViewRootImpl) extract(root)).callWindowFocusChanged(hasFocus);
shadowMainLooper.idleIfPaused();
return this;
}
public ActivityController<T> userLeaving() {
shadowMainLooper.runPaused(() -> getInstrumentation().callActivityOnUserLeaving(component));
return this;
}
public ActivityController<T> pause() {
shadowMainLooper.runPaused(
() -> {
getInstrumentation().callActivityOnPause(component);
currentState = LifecycleState.PAUSED;
});
return this;
}
public ActivityController<T> saveInstanceState(Bundle outState) {
shadowMainLooper.runPaused(
() -> getInstrumentation().callActivityOnSaveInstanceState(component, outState));
return this;
}
public ActivityController<T> stop() {
// Stop and start are tricky cases. Unlike other lifecycle methods such as
// Instrumentation#callActivityOnPause calls Activity#performPause, Activity#performStop calls
// Instrumentation#callActivityOnStop internally so the dependency direction is the opposite.
invokeWhilePaused(
() -> {
if (RuntimeEnvironment.getApiLevel() <= M) {
_component_.performStop();
} else if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
_component_.performStop(true);
} else {
_component_.performStop(true, "stop()");
}
currentState = LifecycleState.STOPPED;
});
return this;
}
@Override
public ActivityController<T> destroy() {
shadowMainLooper.runPaused(
() -> {
getInstrumentation().callActivityOnDestroy(component);
makeActivityEligibleForGc();
currentState = LifecycleState.DESTROYED;
});
return this;
}
private void makeActivityEligibleForGc() {
// Clear WindowManager state for this activity. On real Android this is done by
// ActivityThread.handleDestroyActivity, which is initiated by the window manager
// service.
boolean windowAdded = _component_.getWindowAdded();
if (windowAdded) {
WindowManager windowManager = component.getWindowManager();
windowManager.removeViewImmediate(component.getWindow().getDecorView());
}
if (RuntimeEnvironment.getApiLevel() >= O_MR1) {
// Starting Android O_MR1, there is a leak in Android where `ContextImpl` holds on to the
// activity after being destroyed. This "fixes" the leak in Robolectric only, and will be
// properly fixed in Android S.
component.setAutofillClient(null);
}
}
/**
* Calls the same lifecycle methods on the Activity called by Android the first time the Activity
* is created.
*
* @return Activity controller instance.
*/
public ActivityController<T> setup() {
return create().start().postCreate(null).resume().visible();
}
/**
* Calls the same lifecycle methods on the Activity called by Android when an Activity is restored
* from previously saved state.
*
* @param savedInstanceState Saved instance state.
* @return Activity controller instance.
*/
public ActivityController<T> setup(Bundle savedInstanceState) {
return create(savedInstanceState)
.start()
.restoreInstanceState(savedInstanceState)
.postCreate(savedInstanceState)
.resume()
.visible();
}
public ActivityController<T> newIntent(Intent intent) {
invokeWhilePaused(() -> _component_.onNewIntent(intent));
return this;
}
/**
* Performs a configuration change on the Activity. See {@link #configurationChange(Configuration,
* DisplayMetrics, int)}. The configuration is taken from the application's configuration.
*
* <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
* 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>.
*
* <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
*/
// 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 changedConfig == 0 since it non-intuitively calls onConfigurationChanged
// Can the activity handle itself ALL configuration changes?
if ((getActivityInfo(component.getApplication()).configChanges & changedConfig)
== changedConfig) {
shadowMainLooper.runPaused(
() -> {
component.onConfigurationChanged(newConfiguration);
ViewRootImpl root = getViewRoot();
if (root != null) {
if (RuntimeEnvironment.getApiLevel() <= N_MR1) {
ReflectionHelpers.callInstanceMethod(
root,
"updateConfiguration",
ClassParameter.from(Configuration.class, newConfiguration),
ClassParameter.from(boolean.class, false));
} else {
root.updateConfiguration(Display.INVALID_DISPLAY);
}
}
});
return this;
} else {
@SuppressWarnings("unchecked")
final T recreatedActivity = (T) ReflectionHelpers.callConstructor(component.getClass());
final _Activity_ _recreatedActivity_ = reflector(_Activity_.class, recreatedActivity);
shadowMainLooper.runPaused(
() -> {
// Set flags
_component_.setChangingConfigurations(true);
_component_.setConfigChangeFlags(changedConfig);
// Perform activity destruction
final Bundle outState = new Bundle();
// The order of onPause/onStop/onSaveInstanceState is undefined, but is usually:
// onPause -> onSaveInstanceState -> onStop before API P, and onPause -> onStop ->
// onSaveInstanceState from API P.
// See
// https://developer.android.com/reference/android/app/Activity#onSaveInstanceState(android.os.Bundle) for documentation explained.
// And see ActivityThread#callActivityOnStop for related code.
getInstrumentation().callActivityOnPause(component);
if (RuntimeEnvironment.getApiLevel() < P) {
_component_.performSaveInstanceState(outState);
if (RuntimeEnvironment.getApiLevel() <= M) {
_component_.performStop();
} else {
// API from N to O_MR1(both including)
_component_.performStop(true);
}
} else {
_component_.performStop(true, "configurationChange");
_component_.performSaveInstanceState(outState);
}
// This is the true and complete retained state, including loaders and retained
// fragments.
final Object nonConfigInstance = _component_.retainNonConfigurationInstances();
// This is the activity's "user" state
final Object activityConfigInstance =
nonConfigInstance == null
? null // No framework or user state.
: reflector(_NonConfigurationInstances_.class, nonConfigInstance).getActivity();
getInstrumentation().callActivityOnDestroy(component);
makeActivityEligibleForGc();
// Restore theme in case it was set in the test manually.
// This is not technically what happens but is purely to make this easier to use in
// Robolectric.
ShadowContextThemeWrapper shadowContextThemeWrapper = Shadow.extract(component);
int theme = shadowContextThemeWrapper.callGetThemeResId();
// Setup controller for the new activity
attached = false;
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
// done correctly. For instance, fragment marked as retained is not retained.
attach(/* activityOptions= */ null, /* lastNonConfigurationInstances= */ null);
if (theme != 0) {
recreatedActivity.setTheme(theme);
}
// Set saved non config instance
_recreatedActivity_.setLastNonConfigurationInstances(nonConfigInstance);
ShadowActivity shadowActivity = Shadow.extract(recreatedActivity);
shadowActivity.setLastNonConfigurationInstance(activityConfigInstance);
// Create lifecycle
getInstrumentation().callActivityOnCreate(recreatedActivity, outState);
if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
_recreatedActivity_.performStart();
} else {
_recreatedActivity_.performStart("configurationChange");
}
getInstrumentation().callActivityOnRestoreInstanceState(recreatedActivity, outState);
_recreatedActivity_.onPostCreate(outState);
if (RuntimeEnvironment.getApiLevel() <= O_MR1) {
_recreatedActivity_.performResume();
} else {
_recreatedActivity_.performResume(true, "configurationChange");
}
_recreatedActivity_.onPostResume();
// TODO: Call visible() too.
});
}
return this;
}
/**
* Recreates activity instance which is controlled by this ActivityController.
* NonConfigurationInstances and savedInstanceStateBundle are properly passed into a new instance.
* After the recreation, it brings back its lifecycle state to the original state. The activity
* should not be destroyed yet.
*/
@SuppressWarnings("unchecked")
public ActivityController<T> recreate() {
LifecycleState originalState = currentState;
switch (originalState) {
case INITIAL:
create();
// fall through
case CREATED:
case RESTARTED:
start();
postCreate(null);
// fall through
case STARTED:
resume();
// fall through
case RESUMED:
pause();
// fall through
case PAUSED:
stop();
// fall through
case STOPPED:
break;
default:
throw new IllegalStateException("Cannot recreate activity since it's destroyed already");
}
// Activity#mChangingConfigurations flag should be set prior to Activity recreation process
// starts. ActivityThread does set it on real device but here we simulate the Activity
// recreation process on behalf of ActivityThread so set the flag here. Note we don't need to
// reset the flag to false because this Activity instance is going to be destroyed and disposed.
// https://android.googlesource.com/platform/frameworks/base/+/55418eada51d4f5e6532ae9517af66c50
// ea495c4/core/java/android/app/ActivityThread.java#4806
_component_.setChangingConfigurations(true);
Bundle outState = new Bundle();
saveInstanceState(outState);
Object lastNonConfigurationInstances = _component_.retainNonConfigurationInstances();
destroy();
component = (T) ReflectionHelpers.callConstructor(component.getClass());
_component_ = reflector(_Activity_.class, component);
attached = false;
attach(/* activityOptions= */ null, lastNonConfigurationInstances);
create(outState);
start();
restoreInstanceState(outState);
postCreate(outState);
resume();
postResume();
visible();
windowFocusChanged(true);
// Move back to the original stage. If the original stage was transient stage, it will bring it
// to resumed state to match the on device behavior.
switch (originalState) {
case PAUSED:
pause();
return this;
case STOPPED:
pause();
stop();
return this;
default:
return this;
}
}
// Get the Instrumentation object scoped to the Activity.
private Instrumentation getInstrumentation() {
return _component_.getInstrumentation();
}
/**
* Transitions the underlying Activity to the 'destroyed' state by progressing through the
* appropriate lifecycle events. It frees up any resources and makes the Activity eligible for GC.
*/
@Override
public void close() {
LifecycleState originalState = currentState;
switch (originalState) {
case INITIAL:
case DESTROYED:
return;
case RESUMED:
pause();
// fall through
case PAUSED:
// fall through
case RESTARTED:
// fall through
case STARTED:
stop();
// fall through
case STOPPED:
// fall through
case CREATED:
break;
}
destroy();
}
/** Accessor interface for android.app.Activity.NonConfigurationInstances's internals. */
@ForType(className = "android.app.Activity$NonConfigurationInstances")
interface _NonConfigurationInstances_ {
@Accessor("activity")
Object getActivity();
}
}