chromium/chrome/android/javatests/src/org/chromium/chrome/browser/usage_stats/TabSuspensionTest.java

// Copyright 2019 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.browser.usage_stats;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;

import android.content.Context;
import android.content.Intent;
import android.media.AudioManager;
import android.os.Build;

import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.MediumTest;
import androidx.test.platform.app.InstrumentationRegistry;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import org.chromium.base.IntentUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.DisableIf;
import org.chromium.base.test.util.DisabledTest;
import org.chromium.base.test.util.JniMocker;
import org.chromium.base.test.util.MinAndroidSdkLevel;
import org.chromium.base.test.util.Restriction;
import org.chromium.chrome.browser.ChromeTabbedActivity;
import org.chromium.chrome.browser.ChromeTabbedActivity2;
import org.chromium.chrome.browser.MockSafeBrowsingApiHandler;
import org.chromium.chrome.browser.customtabs.CustomTabActivityTestRule;
import org.chromium.chrome.browser.customtabs.CustomTabsIntentTestUtils;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.multiwindow.MultiWindowTestHelper;
import org.chromium.chrome.browser.multiwindow.MultiWindowUtils;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tab.TabTestUtils;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.R;
import org.chromium.chrome.test.util.ChromeTabUtils;
import org.chromium.chrome.test.util.MenuUtils;
import org.chromium.components.safe_browsing.SafeBrowsingApiBridge;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.test.util.DOMUtils;
import org.chromium.content_public.common.ContentSwitches;
import org.chromium.media.MediaSwitches;
import org.chromium.net.test.EmbeddedTestServer;
import org.chromium.ui.base.PageTransition;
import org.chromium.ui.test.util.UiRestriction;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;

/** Integration tests for {@link PageViewObserver} and {@link SuspendedTab} */
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({
    ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE,
    // Direct all hostnames to EmbeddedTestServer running on 127.0.0.1.
    ContentSwitches.HOST_RESOLVER_RULES + "=MAP * 127.0.0.1",
    "ignore-certificate-errors",
    MediaSwitches.AUTOPLAY_NO_GESTURE_REQUIRED_POLICY
})
@MinAndroidSdkLevel(Build.VERSION_CODES.Q)
public class TabSuspensionTest {
    private static final String STARTING_FQDN = "example.com";
    private static final String DIFFERENT_FQDN = "www.google.com";

    private static final String MEDIA_FILE_TEST_PATH =
            "/content/test/data/media/session/media-session.html";
    private static final String VIDEO_ID = "long-video";

    @Rule
    public ChromeTabbedActivityTestRule mActivityTestRule = new ChromeTabbedActivityTestRule();

    @Rule
    public CustomTabActivityTestRule mCustomTabActivityTestRule = new CustomTabActivityTestRule();

    @Rule public JniMocker jniMocker = new JniMocker();

    @Mock private UsageStatsBridge.Natives mUsageStatsNativeMock;
    @Mock private UsageStatsBridge mUsageStatsBridge;
    @Mock private SuspensionTracker mSuspensionTracker;

    private ChromeTabbedActivity mActivity;
    private PageViewObserver mPageViewObserver;
    private PageViewObserver mPageViewObserver2;
    private TokenTracker mTokenTracker;
    private EventTracker mEventTracker;
    private Tab mTab;
    private EmbeddedTestServer mTestServer;
    private String mStartingUrl;
    private String mDifferentUrl;

    @Before
    public void setUp() throws InterruptedException {
        MockitoAnnotations.initMocks(this);
        SafeBrowsingApiBridge.setSafeBrowsingApiHandler(new MockSafeBrowsingApiHandler());
        jniMocker.mock(UsageStatsBridgeJni.TEST_HOOKS, mUsageStatsNativeMock);
        doReturn(123456L).when(mUsageStatsNativeMock).init(any(), any());
        // TokenTracker and EventTracker hold a promise, and Promises can only be used on a single
        // thread, so we have to initialize them on the thread where they will be used.
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mTokenTracker = new TokenTracker(mUsageStatsBridge);
                    mEventTracker = new EventTracker(mUsageStatsBridge);
                });
        mTestServer =
                EmbeddedTestServer.createAndStartServer(
                        ApplicationProvider.getApplicationContext());
        mStartingUrl = mTestServer.getURLWithHostName(STARTING_FQDN, "/defaultresponse");
        mDifferentUrl = mTestServer.getURLWithHostName(DIFFERENT_FQDN, "/defaultresponse");

        mActivityTestRule.startMainActivityOnBlankPage();
        mActivity = mActivityTestRule.getActivity();
        mTab = mActivity.getActivityTab();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mPageViewObserver =
                            new PageViewObserver(
                                    mActivity,
                                    mActivity.getActivityTabProvider(),
                                    mEventTracker,
                                    mTokenTracker,
                                    mSuspensionTracker,
                                    mActivity.getTabContentManagerSupplier());
                });
    }

    @Test
    @MediumTest
    public void testNavigateToSuspended() {
        doReturn(true).when(mSuspensionTracker).isWebsiteSuspended(STARTING_FQDN);
        startLoadingUrl(mTab, mStartingUrl);
        waitForSuspendedTabToShow(mTab, STARTING_FQDN);

        startLoadingUrl(mTab, mDifferentUrl);
        ChromeTabUtils.waitForTabPageLoaded(mTab, mDifferentUrl);
        assertSuspendedTabHidden(mTab);
    }

    @Test
    @MediumTest
    public void testNavigateToSuspendedDomain_differentPage() {
        doReturn(true).when(mSuspensionTracker).isWebsiteSuspended(STARTING_FQDN);
        startLoadingUrl(mTab, mStartingUrl);
        waitForSuspendedTabToShow(mTab, STARTING_FQDN);

        startLoadingUrl(mTab, mStartingUrl + "foo.html");
        assertSuspendedTabShowing(mTab, STARTING_FQDN);
    }

    @Test
    @MediumTest
    public void testNewTabSuspended() {
        mActivityTestRule.loadUrl(mStartingUrl);

        doReturn(true).when(mSuspensionTracker).isWebsiteSuspended(DIFFERENT_FQDN);
        // We can't use loadUrlInNewTab because the site being suspended will prevent loading from
        // completing, and loadUrlInNewTab expects loading to succeed.
        ChromeTabUtils.newTabFromMenu(
                InstrumentationRegistry.getInstrumentation(), mActivityTestRule.getActivity());
        Tab tab2 = mActivity.getActivityTab();

        startLoadingUrl(tab2, mDifferentUrl);
        waitForSuspendedTabToShow(tab2, DIFFERENT_FQDN);
    }

    @Test
    @MediumTest
    public void testTabSwitchBackToSuspended() {
        mActivityTestRule.loadUrl(mStartingUrl);
        final int originalTabIndex =
                mActivity.getTabModelSelector().getCurrentModel().indexOf(mTab);
        Tab tab2 = mActivityTestRule.loadUrlInNewTab(mDifferentUrl);

        doReturn(true).when(mSuspensionTracker).isWebsiteSuspended(STARTING_FQDN);
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mActivity
                            .getTabModelSelector()
                            .getCurrentModel()
                            .setIndex(originalTabIndex, TabSelectionType.FROM_USER);
                });
        waitForSuspendedTabToShow(mTab, STARTING_FQDN);
    }

    @Test
    @MediumTest
    @DisabledTest(message = "https://crbug.com/1345655")
    public void testEagerSuspension() {
        mActivityTestRule.loadUrl(mStartingUrl);
        CriteriaHelper.pollUiThread(() -> !mTab.isLoading());
        suspendDomain(STARTING_FQDN);
        waitForSuspendedTabToShow(mTab, STARTING_FQDN);

        // Suspending again shouldn't crash or otherwise affect the state of the world.
        suspendDomain(STARTING_FQDN);
        assertSuspendedTabShowing(mTab, STARTING_FQDN);

        // A single un-suspend should be sufficient even though we triggered suspension twice.
        unsuspendDomain(STARTING_FQDN);
        assertSuspendedTabHidden(mTab);
    }

    @Test
    @MediumTest
    @DisableIf.Build(sdk_is_greater_than = 29, message = "https://crbug.com/1036556")
    public void testMediaSuspension() throws TimeoutException {
        mActivityTestRule.loadUrl(
                mTestServer.getURLWithHostName(STARTING_FQDN, MEDIA_FILE_TEST_PATH));
        assertTrue(DOMUtils.isMediaPaused(mTab.getWebContents(), VIDEO_ID));
        DOMUtils.playMedia(mTab.getWebContents(), VIDEO_ID);
        DOMUtils.waitForMediaPlay(mTab.getWebContents(), VIDEO_ID);
        AudioManager audioManager =
                (AudioManager)
                        mActivityTestRule
                                .getActivity()
                                .getApplicationContext()
                                .getSystemService(Context.AUDIO_SERVICE);
        assertTrue(audioManager.isMusicActive());

        suspendDomain(STARTING_FQDN);
        waitForSuspendedTabToShow(mTab, STARTING_FQDN);
        DOMUtils.waitForMediaPauseBeforeEnd(mTab.getWebContents(), VIDEO_ID);
        CriteriaHelper.pollUiThread(
                () -> {
                    return !audioManager.isMusicActive();
                },
                "No audio should be playing",
                5000,
                50);

        unsuspendDomain(STARTING_FQDN);
        assertSuspendedTabHidden(mTab);
        DOMUtils.waitForMediaPlay(mTab.getWebContents(), VIDEO_ID);
        CriteriaHelper.pollUiThread(
                () -> {
                    return audioManager.isMusicActive();
                },
                "Audio should play after un-suspension",
                5000,
                50);
    }

    @Test
    @MediumTest
    @DisabledTest(message = "https://crbug.com/1345655")
    public void testMultiWindow() {
        mActivityTestRule.loadUrl(mStartingUrl);
        Tab tab2 = mActivityTestRule.loadUrlInNewTab(mDifferentUrl);
        CriteriaHelper.pollUiThread(() -> !tab2.isLoading());
        suspendDomain(DIFFERENT_FQDN);
        doReturn(true).when(mSuspensionTracker).isWebsiteSuspended(DIFFERENT_FQDN);
        waitForSuspendedTabToShow(tab2, DIFFERENT_FQDN);

        MultiWindowUtils.getInstance().setIsInMultiWindowModeForTesting(true);

        MenuUtils.invokeCustomMenuActionSync(
                InstrumentationRegistry.getInstrumentation(),
                mActivity,
                R.id.move_to_other_window_menu_id);
        final ChromeTabbedActivity2 activity2 =
                MultiWindowTestHelper.waitForSecondChromeTabbedActivity();
        // Each PageViewObserver is associated with a single ChromeTabbedActivity, so we need to
        // create a new one for the other window. This needs to be done on the UI thread since it
        // can trigger view manipulation.
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mPageViewObserver2 =
                            new PageViewObserver(
                                    activity2,
                                    activity2.getActivityTabProvider(),
                                    mEventTracker,
                                    mTokenTracker,
                                    mSuspensionTracker,
                                    activity2.getTabContentManagerSupplier());
                });

        MultiWindowTestHelper.waitForTabs(
                "CTA", activity2, /* expectedTotalTabCount= */ 1, tab2.getId());
        waitForSuspendedTabToShow(tab2, DIFFERENT_FQDN);

        doReturn(true).when(mSuspensionTracker).isWebsiteSuspended(STARTING_FQDN);
        suspendDomain(STARTING_FQDN);
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mPageViewObserver2.notifySiteSuspensionChanged(DIFFERENT_FQDN, false);
                });
        // Suspending and un-suspending should work in both activities/windows.
        assertSuspendedTabHidden(tab2);
        MultiWindowTestHelper.moveActivityToFront(mActivity);
        waitForSuspendedTabToShow(mTab, STARTING_FQDN);
    }

    @Test
    @MediumTest
    @DisableIf.Build(sdk_is_greater_than = 30, message = "https://crbug.com/1036556")
    public void testTabAddedFromCustomTab() {
        Intent intent =
                CustomTabsIntentTestUtils.createMinimalCustomTabIntent(
                        ApplicationProvider.getApplicationContext(), mStartingUrl);
        IntentUtils.addTrustedIntentExtras(intent);
        mCustomTabActivityTestRule.startCustomTabActivityWithIntent(intent);
        doReturn(true).when(mSuspensionTracker).isWebsiteSuspended(STARTING_FQDN);

        MenuUtils.invokeCustomMenuActionSync(
                InstrumentationRegistry.getInstrumentation(),
                mCustomTabActivityTestRule.getActivity(),
                R.id.open_in_browser_id);

        MultiWindowTestHelper.waitForTabs("CustomTab", mActivity, 2, Tab.INVALID_TAB_ID);
        waitForSuspendedTabToShow(mActivity.getActivityTab(), STARTING_FQDN);
    }

    @Test
    @MediumTest
    public void testTabAddedInBackground() throws ExecutionException {
        Tab bgTab =
                ThreadUtils.runOnUiThreadBlocking(
                        () -> {
                            return mActivity
                                    .getCurrentTabCreator()
                                    .createNewTab(
                                            new LoadUrlParams(mStartingUrl),
                                            TabLaunchType.FROM_LONGPRESS_BACKGROUND,
                                            mTab);
                        });
        ChromeTabUtils.waitForTabPageLoaded(bgTab, mStartingUrl);

        suspendDomain(STARTING_FQDN);
        assertSuspendedTabHidden(bgTab);
    }

    @Test
    @MediumTest
    public void testTabUnsuspendedInBackground() {
        doReturn(true).when(mSuspensionTracker).isWebsiteSuspended(STARTING_FQDN);
        startLoadingUrl(mTab, mStartingUrl);
        waitForSuspendedTabToShow(mTab, STARTING_FQDN);
        final int originalTabIndex =
                mActivity.getTabModelSelector().getCurrentModel().indexOf(mTab);

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mPageViewObserver.notifySiteSuspensionChanged(STARTING_FQDN, false);
                    doReturn(false).when(mSuspensionTracker).isWebsiteSuspended(STARTING_FQDN);
                    mActivity
                            .getTabModelSelector()
                            .getCurrentModel()
                            .setIndex(originalTabIndex, TabSelectionType.FROM_USER);
                });

        assertSuspendedTabHidden(mTab);
    }

    @Test
    @MediumTest
    @DisabledTest(message = "https://crbug.com/1345655")
    public void testNavigationFromSuspendedTabToInterstitial() {
        doReturn(true).when(mSuspensionTracker).isWebsiteSuspended(STARTING_FQDN);
        startLoadingUrl(mTab, mStartingUrl);
        waitForSuspendedTabToShow(mTab, STARTING_FQDN);

        MockSafeBrowsingApiHandler.addMockResponse(
                mDifferentUrl, MockSafeBrowsingApiHandler.SOCIAL_ENGINEERING_CODE);
        startLoadingUrl(mTab, mDifferentUrl);

        waitForSuspendedTabToHide(mTab);
    }

    @Test
    @MediumTest
    public void testRendererCrashOnSuspendedTab() {
        doReturn(true).when(mSuspensionTracker).isWebsiteSuspended(STARTING_FQDN);
        startLoadingUrl(mTab, mStartingUrl);
        waitForSuspendedTabToShow(mTab, STARTING_FQDN);
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    TabTestUtils.simulateCrash(mTab, true);
                    assertSuspendedTabHidden(mTab);
                });
    }

    @Test
    @MediumTest
    // TODO(crbug.com/339003346): Failing on tablets, fix and re-enable.
    @Restriction(UiRestriction.RESTRICTION_TYPE_PHONE)
    public void testSuspendNullCurrentTab() {
        mActivityTestRule.loadUrl(mStartingUrl);
        ChromeTabUtils.closeAllTabs(InstrumentationRegistry.getInstrumentation(), mActivity);

        doReturn(true).when(mSuspensionTracker).isWebsiteSuspended(STARTING_FQDN);
        suspendDomain(STARTING_FQDN);

        // We can't use loadUrlInNewTab because the site being suspended will prevent loading from
        // completing, and loadUrlInNewTab expects loading to succeed.
        ChromeTabUtils.newTabFromMenu(
                InstrumentationRegistry.getInstrumentation(), mActivityTestRule.getActivity());
        Tab tab2 = mActivity.getActivityTab();

        startLoadingUrl(tab2, mStartingUrl);
        waitForSuspendedTabToShow(tab2, STARTING_FQDN);
    }

    @Test
    @MediumTest
    public void testSuspendUninitializedCurrentTab() {
        mActivityTestRule.loadUrl(mStartingUrl);
        ThreadUtils.runOnUiThreadBlocking(() -> mTab.destroy());

        doReturn(true).when(mSuspensionTracker).isWebsiteSuspended(STARTING_FQDN);
        suspendDomain(STARTING_FQDN);
    }

    private void startLoadingUrl(Tab tab, String url) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    tab.loadUrl(new LoadUrlParams(url, PageTransition.TYPED));
                });
    }

    private void assertSuspendedTabHidden(Tab tab) {
        assertSuspendedTabState(tab, false, null);
    }

    private void assertSuspendedTabShowing(Tab tab, String fqdn) {
        assertSuspendedTabState(tab, true, fqdn);
    }

    private void assertSuspendedTabState(Tab tab, boolean showing, String fqdn) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    SuspendedTab suspendedTab =
                            SuspendedTab.from(tab, mActivity.getTabContentManagerSupplier());
                    assertEquals(suspendedTab.isShowing(), showing);
                    assertEquals(suspendedTab.isViewAttached(), showing);
                    assertTrue(
                            (suspendedTab.getFqdn() == null && fqdn == null)
                                    || fqdn.equals(suspendedTab.getFqdn()));
                });
    }

    private void suspendDomain(String domain) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mPageViewObserver.notifySiteSuspensionChanged(domain, true);
                });
    }

    private void unsuspendDomain(String domain) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mPageViewObserver.notifySiteSuspensionChanged(domain, false);
                });
    }

    private void waitForSuspendedTabToShow(Tab tab, String fqdn) {
        CriteriaHelper.pollUiThread(
                () -> {
                    return SuspendedTab.from(tab, mActivity.getTabContentManagerSupplier())
                            .isShowing();
                },
                "Suspended tab should be showing",
                10000,
                50);

        assertSuspendedTabShowing(tab, fqdn);
    }

    private void waitForSuspendedTabToHide(Tab tab) {
        CriteriaHelper.pollUiThread(
                () -> !SuspendedTab.from(tab, mActivity.getTabContentManagerSupplier()).isShowing(),
                "Suspended tab should be hidden",
                10000,
                50);
        assertSuspendedTabHidden(tab);
    }
}