chromium/chrome/android/javatests/src/org/chromium/chrome/browser/display_cutout/DisplayCutoutTestRule.java

// Copyright 2018 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.display_cutout;

import android.graphics.Rect;
import android.os.Build;
import android.view.WindowManager.LayoutParams;

import androidx.annotation.RequiresApi;
import androidx.test.platform.app.InstrumentationRegistry;

import org.hamcrest.Matchers;
import org.json.JSONException;
import org.json.JSONObject;
import org.junit.Assert;

import org.chromium.base.ThreadUtils;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.CriteriaNotSatisfiedException;
import org.chromium.base.test.util.TestThreadUtils;
import org.chromium.chrome.browser.app.ChromeActivity;
import org.chromium.chrome.browser.fullscreen.FullscreenManager;
import org.chromium.chrome.browser.fullscreen.FullscreenOptions;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.test.ChromeActivityTestRule;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.components.browser_ui.display_cutout.DisplayCutoutController;
import org.chromium.content_public.browser.WebContentsObserver;
import org.chromium.content_public.browser.test.util.DOMUtils;
import org.chromium.content_public.browser.test.util.JavaScriptUtils;
import org.chromium.net.test.EmbeddedTestServer;

import java.util.concurrent.TimeoutException;

/**
 * Custom test rule for simulating a Display Cutout. This allows us to test display cutout
 * functionality without having a test device with a cutout.
 *
 * @param <T> The type of {@link ChromeActivity} to use for the test.
 */
@RequiresApi(Build.VERSION_CODES.P)
public class DisplayCutoutTestRule<T extends ChromeActivity> extends ChromeActivityTestRule<T> {
    /** These are the two test safe areas with and without the test cutout. */
    public static final Rect TEST_SAFE_AREA_WITH_CUTOUT = new Rect(10, 20, 30, 40);

    public static final Rect TEST_SAFE_AREA_WITHOUT_CUTOUT = new Rect(0, 0, 0, 0);

    /** These are used for testing different device dip scales. */
    public static final Rect TEST_SAFE_AREA_WITH_CUTOUT_HIGH_DIP = new Rect(4, 8, 12, 16);

    public static final float TEST_HIGH_DIP_SCALE = 2.5f;

    /** These are the different possible viewport fit values. */
    public static final String VIEWPORT_FIT_AUTO = "auto";

    public static final String VIEWPORT_FIT_CONTAIN = "contain";
    public static final String VIEWPORT_FIT_COVER = "cover";

    /** This class has polyfills for Android P+ system apis. */
    public static final class TestDisplayCutoutController extends DisplayCutoutController {
        private boolean mDeviceHasCutout = true;
        private float mDipScale = 1;

        public static TestDisplayCutoutController create(
                Tab tab, final ObservableSupplier<Integer> browserCutoutModeSupplier) {
            DisplayCutoutTabHelper.ChromeDisplayCutoutDelegate delegate =
                    new DisplayCutoutTabHelper.ChromeDisplayCutoutDelegate(tab) {
                        @Override
                        public ObservableSupplier<Integer> getBrowserDisplayCutoutModeSupplier() {
                            return browserCutoutModeSupplier;
                        }
                    };
            return new TestDisplayCutoutController(delegate);
        }

        private TestDisplayCutoutController(DisplayCutoutController.Delegate delegate) {
            super(delegate);
        }

        @Override
        protected void setWindowAttributes(LayoutParams attributes) {
            super.setWindowAttributes(attributes);

            // Apply insets based on new layout mode.
            if (getLayoutInDisplayCutoutMode()
                            == LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
                    && mDeviceHasCutout) {
                onSafeAreaChanged(new Rect(TEST_SAFE_AREA_WITH_CUTOUT));
            } else {
                onSafeAreaChanged(new Rect(TEST_SAFE_AREA_WITHOUT_CUTOUT));
            }
        }

        public int getLayoutInDisplayCutoutMode() {
            return getWindowAttributes().layoutInDisplayCutoutMode;
        }

        @Override
        protected float getDipScale() {
            return mDipScale;
        }

        public void setDipScale(float scale) {
            mDipScale = scale;
        }

        public void setDeviceHasCutout(boolean hasCutout) {
            mDeviceHasCutout = hasCutout;
        }
    }

    /** Listens to fullscreen tab events and tracks the fullscreen state of the tab. */
    private class FullscreenToggleObserver implements FullscreenManager.Observer {
        @Override
        public void onEnterFullscreen(Tab tab, FullscreenOptions options) {
            mIsTabFullscreen = true;
        }

        @Override
        public void onExitFullscreen(Tab tab) {
            mIsTabFullscreen = false;
        }
    }

    /** The test page with the display cutout harness. */
    private static final String DEFAULT_TEST_PAGE =
            "/chrome/test/data/android/display_cutout/test_page.html";

    /** The default test timeout. */
    private static final int TEST_TIMEOUT = 5000;

    /** The embedded test HTTP server that serves the test page. */
    private EmbeddedTestServer mTestServer;

    /** The {@link DisplayCutoutController} to test. */
    private TestDisplayCutoutController mTestController;

    /** Tracks whether the current tab is fullscreen. */
    private boolean mIsTabFullscreen;

    /** The {@link Tab} we are running the test in. */
    private Tab mTab;

    /** The {@link FullscreenManager.Observer} observing fullscreen mode. */
    private FullscreenManager.Observer mListener;

    public DisplayCutoutTestRule(Class<T> activityClass) {
        super(activityClass);
    }

    @Override
    protected void before() throws Throwable {
        super.before();

        startActivity();
        mTab = getActivity().getActivityTab();

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setDisplayCutoutController(TestDisplayCutoutController.create(mTab, null));
                    mListener = new FullscreenToggleObserver();
                    getActivity().getFullscreenManager().addObserver(mListener);
                });
        loadUrl(getTestURL());
    }

    protected void startActivity() {
        ChromeTabbedActivityTestRule rule = new ChromeTabbedActivityTestRule();
        rule.startMainActivityOnBlankPage();
        setActivity((T) rule.getActivity());
    }

    @Override
    protected void after() {
        super.after();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    if (!getActivity().isActivityFinishingOrDestroyed()) {
                        getActivity().getFullscreenManager().removeObserver(mListener);
                    }
                });
    }

    protected String getTestURL() {
        if (mTestServer == null) {
            mTestServer =
                    EmbeddedTestServer.createAndStartServer(
                            InstrumentationRegistry.getInstrumentation().getContext());
        }
        return mTestServer.getURL(DEFAULT_TEST_PAGE);
    }

    protected void setDisplayCutoutController(TestDisplayCutoutController controller) {
        mTestController = controller;
        DisplayCutoutTabHelper.initForTesting(mTab, mTestController);
    }

    /** Set a simulated dip scale for this device. */
    public void setDipScale(float scale) {
        mTestController.setDipScale(scale);
    }

    /** Change whether this device has a display cutout. */
    public void setDeviceHasCutout(boolean hasCutout) {
        mTestController.setDeviceHasCutout(hasCutout);
    }

    /** Get the applied layout in display cutout mode. */
    public int getLayoutInDisplayCutoutMode() {
        return mTestController.getLayoutInDisplayCutoutMode();
    }

    /** Enter fullscreen and wait for the tab to go fullscreen. */
    public void enterFullscreen() throws TimeoutException {
        enterFullscreenUsingButton("fullscreen");
    }

    /** Exit fullscreen and wait for the tab to exit fullscreen. */
    public void exitFullscreen() {
        JavaScriptUtils.executeJavaScript(mTab.getWebContents(), "document.webkitExitFullscreen()");

        CriteriaHelper.pollUiThread(
                () -> !mIsTabFullscreen, TEST_TIMEOUT, CriteriaHelper.DEFAULT_POLLING_INTERVAL);
    }

    /** Wait for the main frame to have a certain applied safe area. */
    public void waitForSafeArea(Rect expected) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        Criteria.checkThat(getAppliedSafeArea(), Matchers.is(expected));
                    } catch (TimeoutException ex) {
                        throw new CriteriaNotSatisfiedException(ex);
                    }
                },
                TEST_TIMEOUT,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);
    }

    /** Wait for the sub frame to have a certain applied safe area. */
    public void waitForSafeAreaOnSubframe(Rect expected) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        Criteria.checkThat(getAppliedSafeAreaOnSubframe(), Matchers.is(expected));
                    } catch (TimeoutException ex) {
                        throw new CriteriaNotSatisfiedException(ex);
                    }
                },
                TEST_TIMEOUT,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);
    }

    /** Wait for the tab to have a certain {@layoutInDisplayCutoutMode. */
    public void waitForLayoutInDisplayCutoutMode(int expected) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    Criteria.checkThat(
                            mTestController.getLayoutInDisplayCutoutMode(), Matchers.is(expected));
                },
                TEST_TIMEOUT,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);
    }

    /** Enter fullscreen on the subframe and wait for the tab to go fullscreen. */
    public void enterFullscreenOnSubframe() throws TimeoutException {
        enterFullscreenUsingButton("subframefull");
    }

    /** Get the applied safe areas from the main frame. */
    public Rect getAppliedSafeArea() throws TimeoutException {
        return getSafeAreaUsingJavaScript("getSafeAreas()");
    }

    /** Get the applied safe areas from the child frame. */
    public Rect getAppliedSafeAreaOnSubframe() throws TimeoutException {
        return getSafeAreaUsingJavaScript("frameWindow.getSafeAreas()");
    }

    /** Set the viewport-fit meta tag on the main frame. */
    public void setViewportFit(String value) throws TimeoutException {
        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                mTab.getWebContents(), "setViewportFit('" + value + "')");
    }

    /** Set the viewport-fit value using internal APIs. */
    public void setViewportFitInternal(@WebContentsObserver.ViewportFitType int value) {
        ThreadUtils.runOnUiThreadBlocking(() -> mTestController.setViewportFit(value));
    }

    /** Get the safe area using JS and parse the JSON result to a Rect. */
    private Rect getSafeAreaUsingJavaScript(String code) throws TimeoutException {
        try {
            String result =
                    JavaScriptUtils.executeJavaScriptAndWaitForResult(mTab.getWebContents(), code);
            JSONObject jsonResult = new JSONObject(result);
            return new Rect(
                    jsonResult.getInt("left"),
                    jsonResult.getInt("top"),
                    jsonResult.getInt("right"),
                    jsonResult.getInt("bottom"));
        } catch (JSONException e) {
            e.printStackTrace();
            Assert.fail("Failed to get safe area");
            return new Rect(0, 0, 0, 0);
        }
    }

    /**
     * Enter fullscreen by clicking on the supplied button and wait for the tab to go fullscreen.
     */
    private void enterFullscreenUsingButton(String id) throws TimeoutException {
        Assert.assertTrue(DOMUtils.clickNode(mTab.getWebContents(), id));

        CriteriaHelper.pollUiThread(
                () -> mIsTabFullscreen, TEST_TIMEOUT, CriteriaHelper.DEFAULT_POLLING_INTERVAL);
        // A subsequently call to exitFullscreen() seems not to work without this, at least for android-13 emulators.
        TestThreadUtils.sleep(500);
    }
}