chromium/android_webview/javatests/src/org/chromium/android_webview/test/VisualStateTest.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.android_webview.test;

import static org.chromium.android_webview.test.AwActivityTestRule.WAIT_TIMEOUT_MS;
import static org.chromium.base.test.util.ScalableTimeout.scaleTimeout;

import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Rect;
import android.util.Base64;

import androidx.test.InstrumentationRegistry;
import androidx.test.filters.SmallTest;

import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.UseParametersRunnerFactory;

import org.chromium.android_webview.AwContents;
import org.chromium.android_webview.AwContents.VisualStateCallback;
import org.chromium.android_webview.AwContentsClient;
import org.chromium.android_webview.test.util.CommonResources;
import org.chromium.android_webview.test.util.GraphicsTestUtils;
import org.chromium.android_webview.test.util.JSUtils;
import org.chromium.android_webview.test.util.JavascriptEventObserver;
import org.chromium.base.ThreadUtils;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.Feature;
import org.chromium.components.embedder_support.util.WebResourceResponseInfo;
import org.chromium.content_public.browser.JavascriptInjector;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.WebContents;

import java.io.ByteArrayInputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

/** Visual state related tests. */
@RunWith(Parameterized.class)
@UseParametersRunnerFactory(AwJUnit4ClassRunnerWithParameters.Factory.class)
public class VisualStateTest extends AwParameterizedTest {
    @Rule public AwActivityTestRule mActivityTestRule;

    private static final String WAIT_FOR_JS_TEST_URL =
            "file:///android_asset/visual_state_waits_for_js_test.html";
    private static final String WAIT_FOR_JS_DETACHED_TEST_URL =
            "file:///android_asset/visual_state_waits_for_js_detached_test.html";
    private static final String ON_PAGE_COMMIT_VISIBLE_TEST_URL =
            "file:///android_asset/visual_state_on_page_commit_visible_test.html";
    private static final String FULLSCREEN_TEST_URL =
            "file:///android_asset/visual_state_during_fullscreen_test.html";
    private static final String UPDATE_COLOR_CONTROL_ID = "updateColorControl";
    private static final String ENTER_FULLSCREEN_CONTROL_ID = "enterFullscreenControl";

    private TestAwContentsClient mContentsClient = new TestAwContentsClient();
    private AwTestContainerView mTestView;

    private static class DelayedInputStream extends FilterInputStream {
        private CountDownLatch mLatch = new CountDownLatch(1);

        DelayedInputStream(InputStream in) {
            super(in);
        }

        @Override
        @SuppressWarnings("Finally")
        public int read() throws IOException {
            try {
                mLatch.await();
            } finally {
                return super.read();
            }
        }

        @Override
        @SuppressWarnings("Finally")
        public int read(byte[] buffer, int byteOffset, int byteCount) throws IOException {
            try {
                mLatch.await();
            } finally {
                return super.read(buffer, byteOffset, byteCount);
            }
        }

        public void allowReads() {
            mLatch.countDown();
        }
    }

    private static class SlowBlueImage extends WebResourceResponseInfo {
        // This image delays returning data for 1 (scaled) second in order to simlate a slow network
        // connection.
        public static final long IMAGE_LOADING_DELAY_MS = scaleTimeout(1000);

        public SlowBlueImage() {
            super(
                    "image/png",
                    "utf-8",
                    new DelayedInputStream(
                            new ByteArrayInputStream(
                                    Base64.decode(
                                            CommonResources.BLUE_PNG_BASE64, Base64.DEFAULT))));
        }

        @Override
        public InputStream getData() {
            final DelayedInputStream stream = (DelayedInputStream) super.getData();
            PostTask.postDelayedTask(
                    TaskTraits.UI_DEFAULT, () -> stream.allowReads(), IMAGE_LOADING_DELAY_MS);
            return stream;
        }
    }

    public VisualStateTest(AwSettingsMutation param) {
        this.mActivityTestRule = new AwActivityTestRule(param.getMutation());
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testVisualStateCallbackIsReceived() throws Throwable {
        mTestView = mActivityTestRule.createAwTestContainerViewOnMainSync(mContentsClient);
        final AwContents awContents = mTestView.getAwContents();
        mActivityTestRule.loadDataSync(
                awContents,
                mContentsClient.getOnPageFinishedHelper(),
                CommonResources.ABOUT_HTML,
                "text/html",
                false);
        final CallbackHelper ch = new CallbackHelper();
        final int chCount = ch.getCallCount();
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            final long requestId =
                                    0x123456789abcdef0L; // ensure requestId is not truncated.
                            awContents.insertVisualStateCallback(
                                    requestId,
                                    new VisualStateCallback() {
                                        @Override
                                        public void onComplete(long id) {
                                            Assert.assertEquals(requestId, id);
                                            ch.notifyCalled();
                                        }
                                    });
                        });
        ch.waitForCallback(chCount);
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testVisualStateCallbackWaitsForContentsToBeOnScreen() throws Throwable {
        // This test loads a page with a blue background color. It then waits for the DOM tree
        // in blink to contain the contents of the blue page (which happens when the onPageFinished
        // event is received). It then flushes the contents and verifies that the blue page
        // background color is drawn when the flush callback is received.
        final LoadUrlParams bluePageUrl = createTestPageUrl("blue");
        final CountDownLatch testFinishedSignal = new CountDownLatch(1);

        final AtomicReference<AwContents> awContentsRef = new AtomicReference<>();
        final long requestId = 10;
        final var visualStateCallback =
                new VisualStateCallback() {
                    @Override
                    public void onComplete(long id) {
                        Assert.assertEquals(requestId, id);
                        Bitmap blueScreenshot =
                                GraphicsTestUtils.drawAwContents(awContentsRef.get(), 1, 1);
                        Assert.assertEquals(Color.BLUE, blueScreenshot.getPixel(0, 0));
                        testFinishedSignal.countDown();
                    }
                };
        mTestView =
                mActivityTestRule.createAwTestContainerViewOnMainSync(
                        new TestAwContentsClient() {
                            @Override
                            public void onPageFinished(String url) {
                                if (bluePageUrl.getUrl().equals(url)) {
                                    awContentsRef
                                            .get()
                                            .insertVisualStateCallback(
                                                    requestId, visualStateCallback);
                                }
                            }
                        });
        final AwContents awContents = mTestView.getAwContents();
        awContentsRef.set(awContents);

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            awContents.setBackgroundColor(Color.RED);
                            awContents.loadUrl(bluePageUrl);

                            // We have just loaded the blue page, but the graphics pipeline is
                            // asynchronous so at this point the WebView still draws red, ie. the
                            // View background color.
                            // Only when the flush callback is received will we know for certain
                            // that the blue page contents are on screen.
                            Bitmap redScreenshot =
                                    GraphicsTestUtils.drawAwContents(awContentsRef.get(), 1, 1);
                            Assert.assertEquals(Color.RED, redScreenshot.getPixel(0, 0));
                        });

        Assert.assertTrue(
                testFinishedSignal.await(
                        AwActivityTestRule.SCALED_WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS));
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    @SkipMutations(reason = "This test depends on AwSettings.setImagesEnabled(true)")
    public void testOnPageCommitVisible() throws Throwable {
        // This test loads a page with a blue background color. It then waits for the DOM tree
        // in blink to contain the contents of the blue page (which happens when the onPageFinished
        // event is received). It then flushes the contents and verifies that the blue page
        // background color is drawn when the flush callback is received.
        final CountDownLatch testFinishedSignal = new CountDownLatch(1);
        final CountDownLatch pageCommitCallbackOccurred = new CountDownLatch(1);

        final AtomicReference<AwContents> awContentsRef = new AtomicReference<>();
        final var visualStateCallback =
                new VisualStateCallback() {
                    @Override
                    public void onComplete(long id) {
                        Bitmap bitmap =
                                GraphicsTestUtils.drawAwContents(awContentsRef.get(), 256, 256);
                        Assert.assertEquals(Color.BLUE, bitmap.getPixel(128, 128));
                        testFinishedSignal.countDown();
                    }
                };
        mTestView =
                mActivityTestRule.createAwTestContainerViewOnMainSync(
                        new TestAwContentsClient() {
                            @Override
                            public void onPageCommitVisible(String url) {
                                Bitmap bitmap =
                                        GraphicsTestUtils.drawAwContents(
                                                awContentsRef.get(), 256, 256);
                                Assert.assertEquals(Color.GREEN, bitmap.getPixel(128, 128));
                                pageCommitCallbackOccurred.countDown();
                            }

                            @Override
                            public WebResourceResponseInfo shouldInterceptRequest(
                                    AwWebResourceRequest request) {
                                if (request.url.equals("intercepted://blue.png")) {
                                    try {
                                        return new SlowBlueImage();
                                    } catch (Throwable t) {
                                        return null;
                                    }
                                }
                                return null;
                            }

                            @Override
                            public void onPageFinished(String url) {
                                super.onPageFinished(url);
                                awContentsRef
                                        .get()
                                        .insertVisualStateCallback(10, visualStateCallback);
                            }
                        });

        final AwContents awContents = mTestView.getAwContents();
        awContentsRef.set(awContents);

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            awContents.setBackgroundColor(Color.RED);

                            awContents.loadUrl(new LoadUrlParams(ON_PAGE_COMMIT_VISIBLE_TEST_URL));

                            // We have just loaded the blue page, but the graphics pipeline is
                            // asynchronous so at this point the WebView still draws red, ie. the
                            // View background color.
                            // Only when the flush callback is received will we know for certain
                            // that the blue page contents are on screen.
                            Bitmap bitmap =
                                    GraphicsTestUtils.drawAwContents(awContentsRef.get(), 256, 256);
                            Assert.assertEquals(Color.RED, bitmap.getPixel(128, 128));
                        });

        Assert.assertTrue(
                pageCommitCallbackOccurred.await(
                        AwActivityTestRule.SCALED_WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS));
        Assert.assertTrue(
                testFinishedSignal.await(
                        AwActivityTestRule.SCALED_WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS));
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testVisualStateCallbackWaitsForJs() throws Throwable {
        // This test checks that when a VisualStateCallback completes the results of executing
        // any block of JS prior to the time at which the callback was inserted will be visible
        // in the next draw. For that it loads a page that changes the background color of
        // the page from JS when a button is clicked.
        final CountDownLatch readyToUpdateColor = new CountDownLatch(1);
        final CountDownLatch testFinishedSignal = new CountDownLatch(1);

        final AtomicReference<AwContents> awContentsRef = new AtomicReference<>();
        final var visualStateCallback =
                new VisualStateCallback() {
                    @Override
                    public void onComplete(long id) {
                        Bitmap blueScreenshot =
                                GraphicsTestUtils.drawAwContents(awContentsRef.get(), 100, 100);
                        Assert.assertEquals(Color.BLUE, blueScreenshot.getPixel(50, 50));
                        readyToUpdateColor.countDown();
                    }
                };
        TestAwContentsClient awContentsClient =
                new TestAwContentsClient() {
                    @Override
                    public void onPageFinished(String url) {
                        super.onPageFinished(url);
                        awContentsRef.get().insertVisualStateCallback(10, visualStateCallback);
                    }
                };
        mTestView = mActivityTestRule.createAwTestContainerViewOnMainSync(awContentsClient);
        final AwContents awContents = mTestView.getAwContents();
        awContentsRef.set(awContents);
        final WebContents webContents = mTestView.getWebContents();
        AwActivityTestRule.enableJavaScriptOnUiThread(awContents);

        // JS will notify this observer once it has changed the background color of the page.
        final JavascriptEventObserver jsObserver = new JavascriptEventObserver();
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> jsObserver.register(awContents.getWebContents(), "jsObserver"));

        mActivityTestRule.loadUrlSync(
                awContents, awContentsClient.getOnPageFinishedHelper(), WAIT_FOR_JS_TEST_URL);

        Assert.assertTrue(
                readyToUpdateColor.await(
                        AwActivityTestRule.SCALED_WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS));
        JSUtils.clickNodeWithUserGesture(webContents, UPDATE_COLOR_CONTROL_ID);
        Assert.assertTrue(jsObserver.waitForEvent(WAIT_TIMEOUT_MS));

        final var visualStateCallback2 =
                new VisualStateCallback() {
                    @Override
                    public void onComplete(long id) {
                        Bitmap redScreenshot =
                                GraphicsTestUtils.drawAwContents(awContents, 100, 100);
                        Assert.assertEquals(Color.RED, redScreenshot.getPixel(50, 50));
                        testFinishedSignal.countDown();
                    }
                };
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> awContents.insertVisualStateCallback(20, visualStateCallback2));

        Assert.assertTrue(
                testFinishedSignal.await(
                        AwActivityTestRule.SCALED_WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS));
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testVisualStateCallbackFromJsDuringFullscreenTransitions() throws Throwable {
        // This test checks that VisualStateCallbacks are delivered correctly during
        // fullscreen transitions. It loads a page, clicks a button to enter fullscreen,
        // then inserts a VisualStateCallback once notified from JS and verifies that when the
        // callback is received the fullscreen contents are rendered correctly in the next draw.
        final CountDownLatch readyToEnterFullscreenSignal = new CountDownLatch(1);
        final CountDownLatch testFinishedSignal = new CountDownLatch(1);

        final AtomicReference<AwContents> awContentsRef = new AtomicReference<>();
        final var visualStateCallback =
                new VisualStateCallback() {
                    @Override
                    public void onComplete(long id) {
                        Bitmap blueScreenshot =
                                GraphicsTestUtils.drawAwContents(awContentsRef.get(), 100, 100);
                        Assert.assertEquals(Color.BLUE, blueScreenshot.getPixel(50, 50));
                        readyToEnterFullscreenSignal.countDown();
                    }
                };
        final FullScreenVideoTestAwContentsClient awContentsClient =
                new FullScreenVideoTestAwContentsClient(
                        mActivityTestRule.getActivity(),
                        mActivityTestRule.isHardwareAcceleratedTest()) {
                    @Override
                    public void onPageFinished(String url) {
                        super.onPageFinished(url);
                        awContentsRef.get().insertVisualStateCallback(10, visualStateCallback);
                    }
                };
        mTestView = mActivityTestRule.createAwTestContainerViewOnMainSync(awContentsClient);
        final AwContents awContents = mTestView.getAwContents();
        awContentsRef.set(awContents);
        final WebContents webContents = mTestView.getWebContents();
        AwActivityTestRule.enableJavaScriptOnUiThread(awContents);
        awContents.getSettings().setFullscreenSupported(true);

        // JS will notify this observer once it has entered fullscreen.
        final JavascriptEventObserver jsObserver = new JavascriptEventObserver();
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> jsObserver.register(awContents.getWebContents(), "jsObserver"));

        mActivityTestRule.loadUrlSync(
                awContents, awContentsClient.getOnPageFinishedHelper(), FULLSCREEN_TEST_URL);

        Assert.assertTrue(
                readyToEnterFullscreenSignal.await(
                        AwActivityTestRule.SCALED_WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS));
        JSUtils.clickNodeWithUserGesture(webContents, ENTER_FULLSCREEN_CONTROL_ID);
        Assert.assertTrue(jsObserver.waitForEvent(WAIT_TIMEOUT_MS));

        final var visualStateCallback2 =
                new VisualStateCallback() {
                    @Override
                    public void onComplete(long id) {
                        // NOTE: We cannot use drawAwContents here because
                        // the web contents are rendered into the custom
                        // view while in fullscreen.
                        Bitmap redScreenshot =
                                GraphicsTestUtils.drawView(
                                        awContentsClient.getCustomView(), 100, 100);
                        Assert.assertEquals(Color.RED, redScreenshot.getPixel(50, 50));
                        testFinishedSignal.countDown();
                    }
                };
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> awContents.insertVisualStateCallback(20, visualStateCallback2));

        Assert.assertTrue(
                testFinishedSignal.await(
                        AwActivityTestRule.SCALED_WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS));
    }

    private AwTestContainerView createDetachedTestContainerViewOnMainSync(
            final AwContentsClient awContentsClient) {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    AwTestContainerView detachedView =
                            mActivityTestRule.createDetachedAwTestContainerView(awContentsClient);
                    detachedView.setClipBounds(new Rect(0, 0, 100, 100));
                    detachedView.measure(100, 100);
                    detachedView.layout(0, 0, 100, 100);
                    return detachedView;
                });
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testVisualStateCallbackWhenContainerViewDetached() throws Throwable {
        final CountDownLatch testFinishedSignal = new CountDownLatch(1);

        final TestAwContentsClient awContentsClient = new TestAwContentsClient();
        mTestView = createDetachedTestContainerViewOnMainSync(awContentsClient);
        final AwContents awContents = mTestView.getAwContents();

        AwActivityTestRule.enableJavaScriptOnUiThread(awContents);

        // JS will notify this observer once it has changed the background color of the page.
        final var visualStateCallback =
                new VisualStateCallback() {
                    @Override
                    public void onComplete(long id) {
                        Bitmap redScreenshot =
                                GraphicsTestUtils.drawAwContents(awContents, 100, 100);
                        Assert.assertEquals(Color.RED, redScreenshot.getPixel(50, 50));
                        testFinishedSignal.countDown();
                    }
                };
        final Object pageChangeNotifier =
                new Object() {
                    public void onPageChanged() {
                        PostTask.postTask(
                                TaskTraits.UI_DEFAULT,
                                () ->
                                        awContents.insertVisualStateCallback(
                                                20, visualStateCallback));
                    }
                };

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            JavascriptInjector.fromWebContents(awContents.getWebContents())
                                    .addPossiblyUnsafeInterface(
                                            pageChangeNotifier, "pageChangeNotifier", null);
                            awContents.loadUrl(WAIT_FOR_JS_DETACHED_TEST_URL);
                        });

        Assert.assertTrue(
                testFinishedSignal.await(
                        AwActivityTestRule.SCALED_WAIT_TIMEOUT_MS, TimeUnit.MILLISECONDS));
    }

    private static final LoadUrlParams createTestPageUrl(String backgroundColor) {
        return LoadUrlParams.createLoadDataParams(
                "<html><body bgcolor=" + backgroundColor + "></body></html>", "text/html", false);
    }
}