chromium/chrome/test/android/javatests/src/org/chromium/chrome/test/util/ActivityTestUtils.java

// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.test.util;

import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertTrue;

import android.app.Activity;
import android.app.Instrumentation;
import android.app.Instrumentation.ActivityMonitor;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.res.Configuration;
import android.os.Bundle;
import android.view.View;

import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityOptionsCompat;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;

import org.hamcrest.Matchers;
import org.junit.Assert;

import org.chromium.base.ApplicationStatus;
import org.chromium.base.IntentUtils;
import org.chromium.base.Log;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.TimeoutTimer;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.settings.SettingsActivity;

import java.util.Locale;
import java.util.concurrent.Callable;

/** Collection of activity utilities. */
public class ActivityTestUtils {
    private static final String TAG = "ActivityTestUtils";

    private static final long ACTIVITY_START_TIMEOUT_MS = 3000L;
    private static final long CONDITION_POLL_INTERVAL_MS = 100;

    /**
     * Captures an activity of a particular type by launching an intent explicitly targeting the
     * activity.
     *
     * @param <T> The type of activity to wait for.
     * @param activityType The class type of the activity.
     * @return The spawned activity.
     */
    public static <T> T waitForActivity(
            final Instrumentation instrumentation, final Class<T> activityType) {
        Runnable intentTrigger =
                new Runnable() {
                    @Override
                    public void run() {
                        Context context =
                                instrumentation.getTargetContext().getApplicationContext();
                        Intent activityIntent = new Intent();
                        activityIntent.setClass(context, activityType);
                        activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                        activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);

                        Bundle optionsBundle =
                                ActivityOptionsCompat.makeCustomAnimation(
                                                context, R.anim.activity_open_enter, 0)
                                        .toBundle();
                        IntentUtils.safeStartActivity(context, activityIntent, optionsBundle);
                    }
                };
        return waitForActivity(instrumentation, activityType, intentTrigger);
    }

    /**
     * Captures an activity of a particular type that is triggered from some action.
     *
     * @param <T> The type of activity to wait for.
     * @param activityType The class type of the activity.
     * @param activityTrigger The action that will trigger the new activity (run in this thread).
     * @return The spawned activity.
     */
    public static <T> T waitForActivity(
            Instrumentation instrumentation, Class<T> activityType, Runnable activityTrigger) {
        Callable<Void> callableWrapper =
                new Callable<Void>() {
                    @Override
                    public Void call() {
                        activityTrigger.run();
                        return null;
                    }
                };

        try {
            return waitForActivityWithTimeout(
                    instrumentation, activityType, callableWrapper, ACTIVITY_START_TIMEOUT_MS);
        } catch (Exception e) {
            // We just ignore checked exceptions here since Runnables can't throw them.
        }
        return null;
    }

    /**
     * Captures an activity of a particular type that is triggered from some action.
     *
     * @param <T> The type of activity to wait for.
     * @param activityType The class type of the activity.
     * @param activityTrigger The action that will trigger the new activity (run in this thread).
     * @return The spawned activity.
     */
    public static <T> T waitForActivity(
            Instrumentation instrumentation, Class<T> activityType, Callable<Void> activityTrigger)
            throws Exception {
        return waitForActivityWithTimeout(
                instrumentation, activityType, activityTrigger, ACTIVITY_START_TIMEOUT_MS);
    }

    /**
     * Captures an activity of a particular type that is triggered from some action.
     *
     * @param activityType The class type of the activity.
     * @param activityTrigger The action that will trigger the new activity (run in this thread).
     * @param timeOut The maximum time to wait for activity creation
     * @return The spawned activity.
     */
    public static <T> T waitForActivityWithTimeout(
            Instrumentation instrumentation,
            Class<T> activityType,
            Callable<Void> activityTrigger,
            long timeOut)
            throws Exception {
        TimeoutTimer timer = new TimeoutTimer(timeOut);
        ActivityMonitor monitor =
                instrumentation.addMonitor(activityType.getCanonicalName(), null, false);

        activityTrigger.call();
        instrumentation.waitForIdleSync();
        Activity activity = monitor.getLastActivity();
        while (activity == null && !timer.isTimedOut()) {
            activity = monitor.waitForActivityWithTimeout(timer.getRemainingMs());
        }
        if (activity == null) logRunningChromeActivities();
        Assert.assertNotNull(activityType.getName() + " did not start in: " + timeOut, activity);

        // Most of the time #waitForIdleSync will include the first layout pass. But once in a while
        // it does not. This is a problem for tests that are going to very quickly try to perform a
        // render of a view.
        View view = activity.getWindow().getDecorView().getRootView();
        CriteriaHelper.pollUiThread(() -> view.getMeasuredWidth() > 0);

        return activityType.cast(activity);
    }

    private static void logRunningChromeActivities() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    StringBuilder builder = new StringBuilder("Running Chrome Activities: ");
                    for (Activity activity : ApplicationStatus.getRunningActivities()) {
                        builder.append(
                                String.format(
                                        Locale.US,
                                        "\n   %s : %d",
                                        activity.getClass().getSimpleName(),
                                        ApplicationStatus.getStateForActivity(activity)));
                    }
                    Log.i(TAG, builder.toString());
                });
    }

    /**
     * Waits for a fragment to be registered by the specified activity.
     *
     * @param activity The activity that owns the fragment.
     * @param fragmentTag The tag of the fragment to be loaded.
     */
    @SuppressWarnings("unchecked")
    public static <T extends Fragment> T waitForFragment(
            AppCompatActivity activity, String fragmentTag) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    Fragment fragment =
                            activity.getSupportFragmentManager().findFragmentByTag(fragmentTag);
                    Criteria.checkThat(fragment, Matchers.notNullValue());
                    if (fragment instanceof DialogFragment) {
                        DialogFragment dialogFragment = (DialogFragment) fragment;
                        Criteria.checkThat(dialogFragment.getDialog(), Matchers.notNullValue());
                        Criteria.checkThat(
                                dialogFragment.getDialog().isShowing(), Matchers.is(true));
                    } else {
                        Criteria.checkThat(fragment.getView(), Matchers.notNullValue());
                    }
                },
                ACTIVITY_START_TIMEOUT_MS,
                CONDITION_POLL_INTERVAL_MS);
        return (T) activity.getSupportFragmentManager().findFragmentByTag(fragmentTag);
    }

    /**
     * Waits until the specified fragment has been attached to the specified activity. Note that
     * we don't guarantee that the fragment is visible. Some UI operations can happen too
     * quickly and we can miss the time that a fragment is visible. This method allows you to get a
     * reference to any fragment that was attached to the activity at any point.
     *
     * @param <T> A subclass of {@link Fragment}.
     * @param activity An instance or subclass of {@link SettingsActivity}.
     * @param fragmentClass The class object for {@link T}.
     * @return A reference to the requested fragment or null.
     */
    @SuppressWarnings("unchecked")
    public static <T extends Fragment> T waitForFragmentToAttach(
            final SettingsActivity activity, final Class<T> fragmentClass) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    Criteria.checkThat(
                            activity.getMainFragment(), Matchers.instanceOf(fragmentClass));
                },
                ACTIVITY_START_TIMEOUT_MS,
                CONDITION_POLL_INTERVAL_MS);
        return (T) activity.getMainFragment();
    }

    /**
     * Rotate device to the target orientation. Do nothing if the screen is already in that
     * orientation. As a best practice, unset orientation in teardown using
     * {@link #clearActivityOrientation(Activity)}.
     *
     * Please disable for automotive devices if your test rotates to portrait orientation.
     * See b/287350212.
     *
     * @param activity The activity on which to set requested orientation.
     * @param orientation The target orientation we want the screen to rotate to. Expects one of
     *                    either {@link Configuration#ORIENTATION_LANDSCAPE} or
     *                    {@link Configuration#ORIENTATION_PORTRAIT}.
     */
    public static void rotateActivityToOrientation(Activity activity, int orientation) {
        if (activity.getResources().getConfiguration().orientation == orientation) return;
        assertTrue(
                "Incorrect orientation supplied.",
                orientation == Configuration.ORIENTATION_LANDSCAPE
                        || orientation == Configuration.ORIENTATION_PORTRAIT);
        activity.setRequestedOrientation(
                orientation == Configuration.ORIENTATION_LANDSCAPE
                        ? ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
                        : ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            activity.getResources().getConfiguration().orientation,
                            is(orientation));
                });
    }

    /**
     * Clear the requested orientation on the given activity (by setting it to unspecified).
     *
     * @param activity The activity on which to clear requested orientation.
     */
    public static void clearActivityOrientation(Activity activity) {
        activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
    }
}