/* * Copyright (C) 2014 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.rule; import static androidx.test.internal.util.Checks.checkNotNull; import static androidx.test.internal.util.Checks.checkState; import android.app.Activity; import android.app.Instrumentation; import android.app.Instrumentation.ActivityResult; import android.content.Intent; import android.os.Bundle; import android.os.Looper; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import android.util.Log; import androidx.test.annotation.UiThreadTest; import androidx.test.internal.runner.junit4.statement.UiThreadStatement; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.runner.MonitoringInstrumentation; import androidx.test.runner.intercepting.SingleActivityFactory; import androidx.test.runner.lifecycle.ActivityLifecycleCallback; import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; import androidx.test.runner.lifecycle.Stage; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import org.junit.rules.TestRule; import org.junit.runner.Description; import org.junit.runners.model.Statement; /** * This rule provides functional testing of a single {@link Activity}. When {@code launchActivity} * is set to true in the constructor, the Activity under test will be launched before each test * annotated with Test * and before methods annotated with Before, and it * will be terminated after the test is completed and methods annotated with After * are finished. * *

The Activity can be manually launched with {@link #launchActivity(Intent)}, and manually * finished with {@link #finishActivity()}. If the Activity is running at the end of the test, the * test rule will finish it. * *

During the duration of the test you will be able to manipulate your Activity directly using * the reference obtained from {@link #getActivity()}. If the Activity is finished and relaunched, * the reference returned by {@link #getActivity()} will always point to the current instance of the * Activity. * *

Consider using {@link androidx.test.ext.junit.rules.ActivityScenarioRule} or {@link * androidx.test.core.app.ActivityScenario} instead. They offer a simpler, and safer way of * controlling Activity lifecycles. * * @param The Activity class under test */ public class ActivityTestRule implements TestRule { private static final String TAG = "ActivityTestRule"; private static final int NO_FLAGS_SET = 0; private static final String FIELD_RESULT_CODE = "mResultCode"; private static final String FIELD_RESULT_DATA = "mResultData"; private final Class activityClass; private final String targetPackage; private final int launchFlags; private final ActivityLifecycleCallback lifecycleCallback = new LifecycleCallback(); private Instrumentation instrumentation; private boolean initialTouchMode = false; private boolean launchActivity = false; private SingleActivityFactory activityFactory; @VisibleForTesting volatile WeakReference activity = makeWeakReference(null); private volatile ActivityResult activityResult; /** * Similar to {@link #ActivityTestRule(Class, boolean)} but with "touch mode" disabled. * * @param activityClass The activity under test. This must be a class in the instrumentation * targetPackage specified in the AndroidManifest.xml * @see ActivityTestRule#ActivityTestRule(Class, boolean, boolean) */ public ActivityTestRule(Class activityClass) { this(activityClass, false); } /** * Similar to {@link #ActivityTestRule(Class, boolean, boolean)} but defaults to launch the * activity under test once per * Test method. It is launched before the first Before * method, and terminated after the last After method. * * @param activityClass The activity under test. This must be a class in the instrumentation * targetPackage specified in the AndroidManifest.xml * @param initialTouchMode true if the Activity should be placed into "touch mode" when started * @see ActivityTestRule#ActivityTestRule(Class, boolean, boolean) */ public ActivityTestRule(Class activityClass, boolean initialTouchMode) { this(activityClass, initialTouchMode, true); } /** * Similar to {@link #ActivityTestRule(Class, String, int, boolean, boolean)} but defaults to * launch the Activity with the default target package name {@link * InstrumentationRegistry#getTargetContext()#getPackageName} and {@link * Intent#FLAG_ACTIVITY_NEW_TASK} launch flag. * * @param activityClass The activity under test. This must be a class in the instrumentation * targetPackage specified in the AndroidManifest.xml * @param initialTouchMode true if the Activity should be placed into "touch mode" when started * @param launchActivity true if the Activity should be launched once per Test method. It * will be launched before the first Before * method, and terminated after the last After * method. */ public ActivityTestRule( Class activityClass, boolean initialTouchMode, boolean launchActivity) { this( activityClass, InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName(), Intent.FLAG_ACTIVITY_NEW_TASK, initialTouchMode, launchActivity); } /** * Creates an {@link ActivityTestRule} for the Activity under test. * * @param activityFactory factory to be used for creating Activity instance * @param initialTouchMode true if the Activity should be placed into "touch mode" when started * @param launchActivity true if the Activity should be launched once per Test method. It * will be launched before the first Before * method, and terminated after the last After * method. */ public ActivityTestRule( SingleActivityFactory activityFactory, boolean initialTouchMode, boolean launchActivity) { this(activityFactory.getActivityClassToIntercept(), initialTouchMode, launchActivity); this.activityFactory = activityFactory; } /** * Creates an {@link ActivityTestRule} for the Activity under test. * * @param activityClass The activity under test. This must be a class in the instrumentation * targetPackage specified in the AndroidManifest.xml * @param initialTouchMode true if the Activity should be placed into "touch mode" when started * @param launchActivity true if the Activity should be launched once per Test method. It * will be launched before the first Before * method, and terminated after the last After * method. * @param targetPackage The name of the target package that the Activity is started under. This * value is passed down to the start Intent using {@link * Intent#setClassName(android.content.Context, String)}. Can not be null. * @param launchFlags launch flags to start the Activity under test. */ public ActivityTestRule( Class activityClass, @NonNull String targetPackage, int launchFlags, boolean initialTouchMode, boolean launchActivity) { instrumentation = InstrumentationRegistry.getInstrumentation(); this.activityClass = activityClass; this.targetPackage = checkNotNull(targetPackage, "targetPackage cannot be null!"); this.launchFlags = launchFlags; this.initialTouchMode = initialTouchMode; this.launchActivity = launchActivity; } /** * Override this method to set up a custom Intent as if supplied to {@link * android.content.Context#startActivity}. Custom Intents provided by this method will take * precedence over default Intents that where created in the constructor but be overridden by any * Intents passed in through {@link #launchActivity(Intent)}. * *

The default Intent (if this method returns null or is not overwritten) is: action = {@link * Intent#ACTION_MAIN} flags = {@link Intent#FLAG_ACTIVITY_NEW_TASK} All other intent fields are * null or empty. * *

If the custom Intent provided by this methods overrides any of the following fields: * *

* *

These custom values will be used to start the Activity. However, if some of these values are * not set, the default values documented in {@link #ActivityTestRule(Class, String, int, boolean, * boolean)} are supplemented. * * @return The Intent as if supplied to {@link android.content.Context#startActivity}. */ protected Intent getActivityIntent() { return null; } /** * Override this method to execute any code that should run before your {@link Activity} is * created and launched. This method is called before each test method, including any method * annotated with * Before. */ protected void beforeActivityLaunched() { // empty by default } /** * Override this method to execute any code that should run after your {@link Activity} is * launched, but before any test code is run including any method annotated with Before. * *

Prefer Before * over this method. This method should usually not be overwritten directly in tests * and only be used by subclasses of ActivityTestRule to get notified when the activity is created * and visible but test runs. */ protected void afterActivityLaunched() { // empty by default } /** * Override this method to execute any code that should run after the currently launched {@link * Activity} is finished. This method is called after each test method, including any method * annotated with After * . * *

Prefer Before * over this method. This method should usually not be overwritten directly in tests * and only be used by subclasses of ActivityTestRule to get notified when the activity is created * and visible but test runs. */ protected void afterActivityFinished() { // empty by default } /** * Returns the reference to the activity under test. * *

The reference to the activity is assigned during the initial creation of the acivity and for * every sinlge {@link Activity#OnResumed()} lifecycle change. * *

Note: Lifecycle changes happen on the UI thread (not the instrumenation thread where * this test code usually executes). Thus, the return value may vary depending on timing. * *

For example, if the activity is finished and relaunched, the reference returned by this * method will point to the new instance of the activity assuming {@link Activity#OnResumed()} was * called prior to calling this method. * *

If the activity wasn't created yet or already finished, {@code null} will be returned. * *

Note: The activity reference is stored in a weak reference which means if the * activity under test is detroyed (ex. back button was pressed) then the system no longer holds a * strong reference to the acitivty and this refernce may get garbage collected. */ public T getActivity() { T hardActivityRef = activity.get(); if (hardActivityRef == null) { Log.w(TAG, "Activity wasn't created yet or already stopped"); } return hardActivityRef; } @Override public Statement apply(final Statement base, Description description) { return new ActivityStatement(base); } /** * Launches the Activity under test. * *

Don't call this method directly, unless you explicitly requested not to lazily launch the * Activity manually using the launchActivity flag in {@link #ActivityTestRule(Class, boolean, * boolean)}. * *

Usage: * *

   *    @Test
   *    public void customIntentToStartActivity() {
   *        Intent intent = new Intent(Intent.ACTION_PICK);
   *        activity = mActivityRule.launchActivity(intent);
   *    }
   * 
* * Note: Custom start Intents provided through this method will take precedence over default * Intents that where created in the constructor and any Intent returned from {@link * #getActivityIntent()}. The same override rules documented in {@link #getActivityIntent()} * apply. * * @param startIntent The Intent that will be used to start the Activity under test. If {@code * startIntent} is null, the Intent returned by {@link ActivityTestRule#getActivityIntent()} * is used. * @return the Activity launched by this rule. */ public T launchActivity(@Nullable Intent startIntent) { // set initial touch mode instrumentation.setInTouchMode(initialTouchMode); // inject custom intent, if provided if (null == startIntent) { startIntent = getActivityIntent(); if (null == startIntent) { Log.w( TAG, "getActivityIntent() returned null using default: " + "Intent(Intent.ACTION_MAIN)"); startIntent = new Intent(Intent.ACTION_MAIN); } } // Set target component if not set Intent if (null == startIntent.getComponent()) { startIntent.setClassName(targetPackage, activityClass.getName()); } // Set launch flags where if not set Intent if (NO_FLAGS_SET == startIntent.getFlags()) { startIntent.addFlags(launchFlags); } beforeActivityLaunched(); // The following cast is correct because the activity we're creating is of the same type as // the one passed in T hardActivityRef = activityClass.cast(instrumentation.startActivitySync(startIntent)); activity = makeWeakReference(hardActivityRef); instrumentation.waitForIdleSync(); if (hardActivityRef != null) { // Notify that Activity was successfully launched ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(lifecycleCallback); afterActivityLaunched(); } else { // Log an error message to logcat/instrumentation, that the Activity failed to launch String errorMessage = String.format("Activity %s, failed to launch", startIntent.getComponent()); Bundle bundle = new Bundle(); bundle.putString(Instrumentation.REPORT_KEY_STREAMRESULT, TAG + " " + errorMessage); instrumentation.sendStatus(0, bundle); Log.e(TAG, errorMessage); } return hardActivityRef; } @VisibleForTesting void setInstrumentation(Instrumentation instrumentation) { this.instrumentation = checkNotNull(instrumentation, "instrumentation cannot be null!"); } /** * Finishes the currently launched Activity. * * @throws IllegalStateException if the Activity is not running or failed to finish it. */ public void finishActivity() { try { if (activity.get() != null) { callFinishOnMainSync(); } } finally { activity = makeWeakReference(null); afterActivityFinished(); // TODO(b/72327935) move down to evaluate } } @VisibleForTesting void callFinishOnMainSync() { try { final T hardActivityRef = activity.get(); runOnUiThread( new Runnable() { @Override public void run() { checkState( hardActivityRef != null, "Activity was not launched. If you manually finished it, you must launch it" + " again before finishing it. "); hardActivityRef.finish(); // If there is an activity result we save it setActivityResultForActivity(hardActivityRef); } }); instrumentation.waitForIdleSync(); } catch (Throwable throwable) { // Should never happen String msg = "Failed to execute activity.finish() on the main thread"; Log.e(TAG, msg, throwable); throw new IllegalStateException(msg, throwable); } } /** * This method can be used to retrieve the {@link ActivityResult} of an Activity that has called * {@link Activity#setResult}. Usually, the result is handled in {@link Activity#onActivityResult} * of the parent Activity, that has called {@link Activity#startActivityForResult}. * *

This method must not be called before {@code Activity.finish} was called or after the * activity was already destroyed. * *

Note: This method assumes {@link Activity#setResult(int)} is called no later than in {@link * Activity#onPause()}. * * @return the {@link ActivityResult} that was set most recently * @throws IllegalStateException if the activity is not in finishing state. */ public ActivityResult getActivityResult() { if (null == activityResult) { // This is required if users manually called .finish() on their activity instead of using // this.finishActivity(). Since .finish() is async there could be a case that our callback // wasn't called just yet. T hardActivityRef = activity.get(); checkNotNull(hardActivityRef, "Activity wasn't created yet or already destroyed!"); try { runOnUiThread( new Runnable() { @Override public void run() { checkState(hardActivityRef.isFinishing(), "Activity is not finishing!"); setActivityResultForActivity(hardActivityRef); } }); } catch (Throwable throwable) { throw new IllegalStateException(throwable); } } return activityResult; } private void setActivityResultForActivity(final T activity) { checkState(Looper.myLooper() == Looper.getMainLooper(), "Must be called on the main thread!"); checkNotNull(activity, "Activity wasn't created yet or already destroyed!"); try { Field resultCodeField = Activity.class.getDeclaredField(FIELD_RESULT_CODE); resultCodeField.setAccessible(true); Field resultDataField = Activity.class.getDeclaredField(FIELD_RESULT_DATA); resultDataField.setAccessible(true); activityResult = new ActivityResult( (int) resultCodeField.get(activity), (Intent) resultDataField.get(activity)); } catch (NoSuchFieldException e) { String msg = "Looks like the Android Activity class has changed its" + "private fields for mResultCode or mResultData. " + "Time to update the reflection code."; Log.e(TAG, msg, e); throw new RuntimeException(msg, e); } catch (IllegalAccessException e) { String msg = "Field mResultCode or mResultData is not accessible"; Log.e(TAG, msg, e); throw new RuntimeException(msg, e); } } /** * Helper method for running part of a method on the UI thread, blocking until it is complete. * *

Note: In most cases it is simpler to annotate the test method with {@link UiThreadTest}. * *

Use this method if you need to switch in and out of the UI thread within your method. * * @param runnable runnable containing test code in the {@link Runnable#run()} method * @see androidx.test.annotation.UiThreadTest */ public void runOnUiThread(final Runnable runnable) throws Throwable { UiThreadStatement.runOnUiThread(runnable); } /** * Statement * that finishes the activity after the test was executed */ private class ActivityStatement extends Statement { private final Statement base; public ActivityStatement(Statement base) { this.base = base; } @Override public void evaluate() throws Throwable { MonitoringInstrumentation instrumentation = ActivityTestRule.this.instrumentation instanceof MonitoringInstrumentation ? (MonitoringInstrumentation) ActivityTestRule.this.instrumentation : null; try { if (activityFactory != null && instrumentation != null) { instrumentation.interceptActivityUsing(activityFactory); } if (launchActivity) { launchActivity(getActivityIntent()); } base.evaluate(); } finally { if (instrumentation != null) { instrumentation.useDefaultInterceptingActivityFactory(); } T hardActivityRef = activity.get(); if (hardActivityRef != null) { finishActivity(); } activityResult = null; ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(lifecycleCallback); } } } @VisibleForTesting WeakReference makeWeakReference(T activity) { return new WeakReference(activity); } /** * Activity lifecycle callback which ensures to release a reference on the activity under test * after lifecycle changes. This is done to ensure that we don't leak the original Activity under * test and at the same time have a reference to the currently visible activity. * *

Note: this callback is run on the main thread! */ private class LifecycleCallback implements ActivityLifecycleCallback { @Override public void onActivityLifecycleChanged(Activity activity, Stage stage) { if (activityClass.isInstance(activity)) { if (Stage.RESUMED == stage) { ActivityTestRule.this.activity = makeWeakReference(activityClass.cast(activity)); } else if (Stage.PAUSED == stage) { // If there is an activity result we save it if (activity.isFinishing() && activityResult != null) { setActivityResultForActivity(activityClass.cast(activity)); } } } } } }