chromium/android_webview/javatests/src/org/chromium/android_webview/test/AwMediaIntegrityApiTest.java

// Copyright 2024 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 android.net.Uri;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.test.filters.MediumTest;

import org.junit.Assert;
import org.junit.Before;
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.JsReplyProxy;
import org.chromium.android_webview.WebMessageListener;
import org.chromium.android_webview.common.AwFeatures;
import org.chromium.android_webview.common.MediaIntegrityApiStatus;
import org.chromium.android_webview.common.MediaIntegrityErrorCode;
import org.chromium.android_webview.common.MediaIntegrityErrorWrapper;
import org.chromium.android_webview.common.MediaIntegrityProvider;
import org.chromium.android_webview.common.PlatformServiceBridge;
import org.chromium.android_webview.common.PlatformServiceBridgeImpl;
import org.chromium.android_webview.common.ValueOrErrorCallback;
import org.chromium.android_webview.test.AwActivityTestRule.TestDependencyFactory;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.content_public.browser.MessagePayload;
import org.chromium.content_public.browser.MessagePort;
import org.chromium.net.test.util.TestWebServer;

import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.TimeoutException;

/**
 * Test class for the Android Media Integrity API implemented as a Blink extension.
 *
 * <p>This feature requires the older version to be disabled.
 */
@RunWith(Parameterized.class)
@UseParametersRunnerFactory(AwJUnit4ClassRunnerWithParameters.Factory.class)
@Batch(Batch.PER_CLASS)
public class AwMediaIntegrityApiTest extends AwParameterizedTest {

    private static final long CLOUD_PROJECT_NUMBER = 123;
    private static final String CONTENT_BINDING_HASH = "content_binding";
    private static final String UNTRUSTWORTHY_OR_NON_HTTP_HTTPS_ERROR_MESSAGE =
            "NotSupportedError: Failed to execute 'getExperimentalMediaIntegrityTokenProvider' on"
                + " 'WebView': getExperimentalMediaIntegrityTokenProvider: can only be used from"
                + " trustworthy http/https origins";

    @Rule public AwActivityTestRule mRule;

    private TestAwContentsClient mContentsClient;
    private AwContents mAwContents;

    private final TestWebMessageListener mMessageListener = new TestWebMessageListener();
    private MockPlatformServiceBridge mPlatformBridge;

    public AwMediaIntegrityApiTest(AwSettingsMutation param) {
        mRule = new AwActivityTestRule(param.getMutation());
    }

    @Before
    public void setUp() throws Throwable {
        mPlatformBridge = new MockPlatformServiceBridge();
        PlatformServiceBridge.injectInstance(mPlatformBridge);

        mContentsClient = new TestAwContentsClient();
        AwTestContainerView mTestContainerView =
                mRule.createAwTestContainerViewOnMainSync(
                        mContentsClient, false, new TestDependencyFactory());
        mAwContents = mTestContainerView.getAwContents();
        AwActivityTestRule.enableJavaScriptOnUiThread(mAwContents);

        ThreadUtils.runOnUiThreadBlocking(
                () ->
                        mAwContents.addWebMessageListener(
                                "testListener", new String[] {"*"}, mMessageListener));

        // Ensure API status is ENABLED before the first request.
        mAwContents
                .getSettings()
                .setWebViewIntegrityApiStatus(
                        MediaIntegrityApiStatus.ENABLED, Collections.emptyMap());

        // TODO(crbug.com/330151742): AWMI doesn't use the origin of the base URL set by loads from
        // loadDataWithBaseUrl. For now, use a TestWebServer to load a default HTTPS page.
        try (TestWebServer server = TestWebServer.startSsl()) {
            String url = server.setEmptyResponse("");
            mRule.loadUrlSync(mAwContents, mContentsClient.getOnPageFinishedHelper(), url);
        }
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testApiSurfaceExposed() throws Exception {
        // Check the method name is exposed
        assertJsTruthy("android.webview.getExperimentalMediaIntegrityTokenProvider");

        // Check that the MediaIntegrityTokenProvider class is exposed and has an accessor for the
        // cloudProjectNumber
        assertJsTruthy("android.webview.MediaIntegrityTokenProvider");
        assertJsTruthy(
                "Object.hasOwn(android.webview.MediaIntegrityTokenProvider.prototype,"
                        + " \"cloudProjectNumber\")");

        // Check that the MediaIntegrityError is exposed and has a mediaIntegrityErrorName property.
        assertJsTruthy("android.webview.MediaIntegrityError");
        assertJsTruthy(
                "Object.hasOwn(android.webview.MediaIntegrityError.prototype,"
                        + " \"mediaIntegrityErrorName\")");
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "disable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testApiSurfaceNotExposedWhenFeatureDisabled() throws Exception {
        assertJsTruthy("!('android' in window)");
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testProviderGetterNotExposedForDataUris() throws Throwable {
        mRule.loadDataSync(
                mAwContents, mContentsClient.getOnPageFinishedHelper(), "", "text/html", false);
        assertNotExposed();
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testProviderGetterExposedButRejectedForDataUrisWithHttpsBaseUrls()
            throws Throwable {
        // An HTTPS base URL has a secure context, unlike a plain data URL. This exposes the API,
        // but we deliberately reject the data URL in the implementation.
        mRule.loadDataWithBaseUrlSync(
                mAwContents,
                mContentsClient.getOnPageFinishedHelper(),
                "",
                "text/html",
                false,
                "https://example.com/",
                null);
        final String testScript =
                getTestScript(CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH));
        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.NON_RECOVERABLE_ERROR),
                runTestScriptAndWaitForResult(testScript));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testProviderGetterNotExposedForAboutBlank() throws Throwable {
        mRule.loadUrlSync(mAwContents, mContentsClient.getOnPageFinishedHelper(), "about:blank");
        assertNotExposed();
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testProviderGetterNotExposedForPlaintextHttp() throws Throwable {
        mRule.loadDataWithBaseUrlSync(
                mAwContents,
                mContentsClient.getOnPageFinishedHelper(),
                "",
                "text/html",
                false,
                "http://example.com/",
                null);
        assertNotExposed();
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testProviderGetterUseableForLocalhostHttp() throws Throwable {
        try (TestWebServer server = TestWebServer.start()) {
            String url = server.setEmptyResponse("");
            mRule.loadUrlSync(mAwContents, mContentsClient.getOnPageFinishedHelper(), url);
        }
        String mockToken = "abc123def456";
        MockTokenProvider mockTokenProvider = new MockTokenProvider();
        mockTokenProvider.addRequestToken(CONTENT_BINDING_HASH, mockToken);

        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProvider);

        String actualToken =
                runTestScriptAndWaitForResult(
                        getTestScript(
                                CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH)));
        Assert.assertEquals(mockToken, actualToken);

        // Assert that the token manager was instantiated with the correct cloud project number
        Assert.assertEquals(1, mPlatformBridge.getTotalProviderCallCount());
        Assert.assertEquals(
                1,
                mPlatformBridge.getProviderCallCount(
                        CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED));

        // Assert that the content binding hash was passed to the TokenProvider
        Assert.assertEquals(1, mockTokenProvider.getTotalCallCount());
        Assert.assertEquals(1, mockTokenProvider.getCallCount(CONTENT_BINDING_HASH));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testProviderGetterExposedButRejectedForFileUris() throws Throwable {
        mRule.loadUrlSync(
                mAwContents,
                mContentsClient.getOnPageFinishedHelper(),
                "file:///android_asset/hello.html");
        final String testScript =
                getTestScript(CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH));
        Assert.assertEquals(
                UNTRUSTWORTHY_OR_NON_HTTP_HTTPS_ERROR_MESSAGE,
                runTestScriptAndWaitForResult(testScript));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testProviderGetterExposedButRejectedForContentUris() throws Throwable {
        final String testHtmlContentPath = "hello.html";
        final String testHtmlContent =
                "<!DOCTYPE html><html><body>Hello. I'm from a content-provider.</body></html>";
        TestContentProvider.register(
                testHtmlContentPath, "text/html", testHtmlContent.getBytes(StandardCharsets.UTF_8));
        mAwContents.getSettings().setAllowContentAccess(true);
        mRule.loadUrlSync(
                mAwContents,
                mContentsClient.getOnPageFinishedHelper(),
                TestContentProvider.createContentUrl(testHtmlContentPath));
        final String testScript =
                getTestScript(CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH));
        Assert.assertEquals(
                UNTRUSTWORTHY_OR_NON_HTTP_HTTPS_ERROR_MESSAGE,
                runTestScriptAndWaitForResult(testScript));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testTokenProviderIsNotConstructable() throws Exception {
        // Try to construct a new token provider and turn the error into a string.
        String script =
                """
            let result = "";
            try {
              let provider = new android.webview.MediaIntegrityTokenProvider(123, {});
              result = "unexpected";
            } catch (e) {
              result = "" + e; // Convert to string
            }
            result; // Respond with result.
            """;

        String result =
                mRule.executeJavaScriptAndWaitForResult(mAwContents, mContentsClient, script);
        Assert.assertEquals("\"TypeError: Illegal constructor\"", result);
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testAbleToGetTokenProviderAndToken() throws Exception {
        String mockToken = "abc123def456";
        MockTokenProvider mockTokenProvider = new MockTokenProvider();
        mockTokenProvider.addRequestToken(CONTENT_BINDING_HASH, mockToken);

        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProvider);

        String actualToken =
                runTestScriptAndWaitForResult(
                        getTestScript(
                                CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH)));
        Assert.assertEquals(mockToken, actualToken);

        // Assert that the token manager was instantiated with the correct cloud project number
        Assert.assertEquals(1, mPlatformBridge.getTotalProviderCallCount());
        Assert.assertEquals(
                1,
                mPlatformBridge.getProviderCallCount(
                        CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED));

        // Assert that the content binding hash was passed to the TokenProvider
        Assert.assertEquals(1, mockTokenProvider.getTotalCallCount());
        Assert.assertEquals(1, mockTokenProvider.getCallCount(CONTENT_BINDING_HASH));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testErrorWhenAppDisablesApiGlobally() throws Exception {
        mAwContents
                .getSettings()
                .setWebViewIntegrityApiStatus(
                        MediaIntegrityApiStatus.DISABLED, Collections.emptyMap());

        String testScript =
                getTestScript(CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH));

        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.API_DISABLED_BY_APPLICATION),
                runTestScriptAndWaitForResult(testScript));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testErrorWhenAppDisabledApiForUrl() throws Exception {
        String testScript =
                getTestScript(CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH));

        try (TestWebServer server = TestWebServer.startSsl()) {
            String url = server.setEmptyResponse("");
            Map<String, @MediaIntegrityApiStatus Integer> rules =
                    Map.of(url, MediaIntegrityApiStatus.DISABLED);
            mAwContents
                    .getSettings()
                    .setWebViewIntegrityApiStatus(MediaIntegrityApiStatus.ENABLED, rules);
            mRule.loadUrlSync(mAwContents, mContentsClient.getOnPageFinishedHelper(), url);
        }

        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.API_DISABLED_BY_APPLICATION),
                runTestScriptAndWaitForResult(testScript));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testErrorWhenApiDisabledForSourceButEnabledForTopLevel() throws Exception {
        final String result;
        try (final TestWebServer topLevelServer = TestWebServer.startSsl();
                final TestWebServer sourceServer = TestWebServer.startAdditionalSsl()) {
            final String testScript =
                    getTestScript(CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH));
            final String sourceHtml = "<script>" + testScript + "</script>";
            final String sourceUrl =
                    sourceServer.setResponse("/", sourceHtml, Collections.emptyList());
            final String topLevelHtml = "<iframe src=\"" + sourceUrl + "\"></iframe>";
            final String topLevelUrl =
                    topLevelServer.setResponse("/", topLevelHtml, Collections.emptyList());
            final Map<String, @MediaIntegrityApiStatus Integer> rules =
                    Map.of(sourceServer.getResponseUrl(""), MediaIntegrityApiStatus.DISABLED);
            mAwContents
                    .getSettings()
                    .setWebViewIntegrityApiStatus(MediaIntegrityApiStatus.ENABLED, rules);
            final int callCount = mMessageListener.getCurrentCallbackCount();
            mRule.loadUrlSync(mAwContents, mContentsClient.getOnPageFinishedHelper(), topLevelUrl);
            result = mMessageListener.waitForResponse(callCount);
        }

        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.API_DISABLED_BY_APPLICATION),
                result);
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testGetTokenWhenApiEnabledForSourceButDisabledForTopLevel() throws Exception {
        final String mockToken = "abc123def456";
        final MockTokenProvider mockTokenProvider = new MockTokenProvider();
        mockTokenProvider.addRequestToken(CONTENT_BINDING_HASH, mockToken);
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProvider);

        final String result;
        try (final TestWebServer topLevelServer = TestWebServer.startSsl();
                final TestWebServer sourceServer = TestWebServer.startAdditionalSsl()) {
            final String testScript =
                    getTestScript(CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH));
            final String sourceHtml = "<script>" + testScript + "</script>";
            final String sourceUrl =
                    sourceServer.setResponse("/", sourceHtml, Collections.emptyList());
            final String topLevelHtml = "<iframe src=\"" + sourceUrl + "\"></iframe>";
            final String topLevelUrl =
                    topLevelServer.setResponse("/", topLevelHtml, Collections.emptyList());
            final Map<String, @MediaIntegrityApiStatus Integer> rules =
                    Map.of(sourceServer.getResponseUrl(""), MediaIntegrityApiStatus.ENABLED);
            mAwContents
                    .getSettings()
                    .setWebViewIntegrityApiStatus(MediaIntegrityApiStatus.DISABLED, rules);
            final int callCount = mMessageListener.getCurrentCallbackCount();
            mRule.loadUrlSync(mAwContents, mContentsClient.getOnPageFinishedHelper(), topLevelUrl);
            result = mMessageListener.waitForResponse(callCount);
        }

        Assert.assertEquals(mockToken, result);
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testCloudProjectNumberAccessibleOnTokenProvider() throws Exception {
        MockTokenProvider mockTokenProvider = new MockTokenProvider();
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProvider);

        String script =
                String.format(
                        Locale.ENGLISH,
                        """
                window.android.webview.getExperimentalMediaIntegrityTokenProvider(
                                                                         {cloudProjectNumber: %d})
                    .then(provider => testListener.postMessage("" + provider.cloudProjectNumber))
                    .catch(e => {
                      if (e.mediaIntegrityErrorName !== undefined
                          && e.mediaIntegrityErrorName !== null) {
                        testListener.postMessage(e.mediaIntegrityErrorName);
                      } else {
                        testListener.postMessage("" + e); // Convert error to string for matching.
                      }
                    });
                """,
                        CLOUD_PROJECT_NUMBER);
        String actualCloudProjectNumber = runTestScriptAndWaitForResult(script);
        Assert.assertEquals("" + CLOUD_PROJECT_NUMBER, actualCloudProjectNumber);
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testTokenProviderReusedWhenUsingSamePartition() throws Exception {
        String mockToken = "abc123def456";

        MockTokenProvider mockTokenProvider = new MockTokenProvider();
        // Add 2 responses
        mockTokenProvider.addRequestToken(CONTENT_BINDING_HASH, mockToken);
        mockTokenProvider.addRequestToken(CONTENT_BINDING_HASH, mockToken);
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProvider);

        String testScript =
                getTestScript(CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH));

        Assert.assertEquals(mockToken, runTestScriptAndWaitForResult(testScript));
        Assert.assertEquals(mockToken, runTestScriptAndWaitForResult(testScript));

        // Assert that the token manager was only instantiated once
        Assert.assertEquals(1, mPlatformBridge.getTotalProviderCallCount());
        Assert.assertEquals(2, mockTokenProvider.getCallCount(CONTENT_BINDING_HASH));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testTokenProviderNotReusedAfterApiModeChange() throws Exception {
        String mockToken = "abc123def456";

        MockTokenProvider mockTokenProvider = new MockTokenProvider();
        mockTokenProvider.addRequestToken(CONTENT_BINDING_HASH, mockToken);
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProvider);
        MockTokenProvider mockTokenProviderNoAppIdentity = new MockTokenProvider();
        mockTokenProviderNoAppIdentity.addRequestToken(CONTENT_BINDING_HASH, mockToken);
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER,
                MediaIntegrityApiStatus.ENABLED_WITHOUT_APP_IDENTITY,
                mockTokenProviderNoAppIdentity);

        String testScript =
                getTestScript(CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH));

        // Ensure API status is ENABLED before the first request
        mAwContents
                .getSettings()
                .setWebViewIntegrityApiStatus(
                        MediaIntegrityApiStatus.ENABLED, Collections.emptyMap());

        Assert.assertEquals(mockToken, runTestScriptAndWaitForResult(testScript));

        // Change the API status
        mAwContents
                .getSettings()
                .setWebViewIntegrityApiStatus(
                        MediaIntegrityApiStatus.ENABLED_WITHOUT_APP_IDENTITY,
                        Collections.emptyMap());

        Assert.assertEquals(mockToken, runTestScriptAndWaitForResult(testScript));

        // Assert that 2 token managers were instantiated, indicating the new API status was used.
        Assert.assertEquals(
                1,
                mPlatformBridge.getProviderCallCount(
                        CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED));
        Assert.assertEquals(
                1,
                mPlatformBridge.getProviderCallCount(
                        CLOUD_PROJECT_NUMBER,
                        MediaIntegrityApiStatus.ENABLED_WITHOUT_APP_IDENTITY));

        Assert.assertEquals(1, mockTokenProvider.getTotalCallCount());
        Assert.assertEquals(1, mockTokenProviderNoAppIdentity.getTotalCallCount());
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testTokenProviderNotReusedAcrossDistinctOrigins() throws Exception {
        String mockTokenTestServer1 = "abc123def456";
        String mockTokenTestServer2 = "555555555";

        String contentBindingTestServer1 = "test_server1";
        String contentBindingTestServer2 = "test_server2";

        long cloudProjectNumberTestServer1 = 1234;
        long cloudProjectNumberTestServer2 = 9876;

        MockTokenProvider mockTokenProviderTestServer1 = new MockTokenProvider();
        mockTokenProviderTestServer1.addRequestToken(
                contentBindingTestServer1, mockTokenTestServer1);
        mPlatformBridge.addProviderResponse(
                cloudProjectNumberTestServer1,
                MediaIntegrityApiStatus.ENABLED,
                mockTokenProviderTestServer1);

        MockTokenProvider mockTokenProviderTestServer2 = new MockTokenProvider();
        mockTokenProviderTestServer2.addRequestToken(
                contentBindingTestServer2, mockTokenTestServer2);
        mPlatformBridge.addProviderResponse(
                cloudProjectNumberTestServer2,
                MediaIntegrityApiStatus.ENABLED,
                mockTokenProviderTestServer2);

        try (TestWebServer server1 = TestWebServer.startSsl()) {
            try (TestWebServer server2 = TestWebServer.startAdditionalSsl()) {
                String url1 = server1.setEmptyResponse("/");
                mRule.loadUrlSync(mAwContents, mContentsClient.getOnPageFinishedHelper(), url1);

                Assert.assertEquals(
                        mockTokenTestServer1,
                        runTestScriptAndWaitForResult(
                                getTestScript(
                                        cloudProjectNumberTestServer1,
                                        asStringConstant(contentBindingTestServer1))));

                String url2 = server2.setEmptyResponse("/");
                mRule.loadUrlSync(mAwContents, mContentsClient.getOnPageFinishedHelper(), url2);

                Assert.assertEquals(
                        mockTokenTestServer2,
                        runTestScriptAndWaitForResult(
                                getTestScript(
                                        cloudProjectNumberTestServer2,
                                        asStringConstant(contentBindingTestServer2))));
            }
        }

        // Assert that the token manager was instantiated twice.
        Assert.assertEquals(2, mPlatformBridge.getTotalProviderCallCount());
        // Assert that each token provider was called with the expected content binding.
        Assert.assertEquals(
                1, mockTokenProviderTestServer1.getCallCount(contentBindingTestServer1));
        Assert.assertEquals(
                1, mockTokenProviderTestServer2.getCallCount(contentBindingTestServer2));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testTokenProviderNotReusedAcrossDistinctPartyIFrames() throws Exception {
        String mockTokenA = "abc123def456";
        String mockTokenB = "555555555";

        MockTokenProvider mockTokenProviderA = new MockTokenProvider();
        mockTokenProviderA.addRequestToken(CONTENT_BINDING_HASH, mockTokenA);
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProviderA);

        MockTokenProvider mockTokenProviderB = new MockTokenProvider();
        mockTokenProviderB.addRequestToken(CONTENT_BINDING_HASH, mockTokenB);
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProviderB);

        try (TestWebServer server = TestWebServer.startSsl();
                TestWebServer thirdPartyServer = TestWebServer.startAdditionalSsl();
                TestWebServer fourthPartyServer = TestWebServer.startAdditionalSsl()) {

            String testScript =
                    getTestScript(CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH));

            String framePage = "<script>" + testScript + "</script>";
            String thirdPartyFrameUrl =
                    thirdPartyServer.setResponse("/frame", framePage, Collections.emptyList());
            String fourthPartyFrameUrl =
                    fourthPartyServer.setResponse("/frame", framePage, Collections.emptyList());

            String mainPage =
                    String.format(
                            """
                  <html>
                  <iframe src="%s"></iframe>
                  <iframe src="%s"></iframe>
                  </html>
                  """,
                            thirdPartyFrameUrl, fourthPartyFrameUrl);

            String url = server.setResponse("/", mainPage, Collections.emptyList());

            int callCount = mMessageListener.getCurrentCallbackCount();
            mRule.loadUrlSync(mAwContents, mContentsClient.getOnPageFinishedHelper(), url);
            List<String> responses = mMessageListener.waitForMultipleResponses(callCount, 2);
            Assert.assertEquals(Set.of(mockTokenA, mockTokenB), Set.copyOf(responses));
        }

        // Assert that the token manager was instantiated twice
        Assert.assertEquals(2, mPlatformBridge.getTotalProviderCallCount());

        Assert.assertEquals(1, mockTokenProviderA.getTotalCallCount());
        Assert.assertEquals(1, mockTokenProviderB.getTotalCallCount());
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testTokenProviderNotReusedIfInvalid() throws Exception {

        MockTokenProvider mockTokenProvider1 = new MockTokenProvider();
        mockTokenProvider1.addRequestError(
                CONTENT_BINDING_HASH, MediaIntegrityErrorCode.TOKEN_PROVIDER_INVALID);
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProvider1);

        MockTokenProvider mockTokenProvider2 = new MockTokenProvider();
        mockTokenProvider2.addRequestError(
                CONTENT_BINDING_HASH, MediaIntegrityErrorCode.TOKEN_PROVIDER_INVALID);
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProvider2);

        String testScript =
                getTestScript(CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH));

        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.TOKEN_PROVIDER_INVALID),
                runTestScriptAndWaitForResult(testScript));

        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.TOKEN_PROVIDER_INVALID),
                runTestScriptAndWaitForResult(testScript));

        // Assert that the integrity manager was called 2 times, indicating that the application
        // is allowed to create a new provider.
        Assert.assertEquals(2, mPlatformBridge.getTotalProviderCallCount());
        Assert.assertEquals(1, mockTokenProvider1.getTotalCallCount());
        Assert.assertEquals(1, mockTokenProvider2.getTotalCallCount());
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testTokenRequestAcceptsEmptyString() throws Exception {
        String mockToken = "abc123def456";

        MockTokenProvider mockTokenProvider = new MockTokenProvider();
        mockTokenProvider.addRequestToken("", mockToken);
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProvider);

        String testScript = getTestScript(CLOUD_PROJECT_NUMBER, asStringConstant(""));
        String actualResponse = runTestScriptAndWaitForResult(testScript);

        Assert.assertEquals(mockToken, actualResponse);

        Assert.assertEquals(1, mockTokenProvider.getCallCount(""));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testTokenRequestAcceptsNull() throws Exception {
        String mockToken = "abc123def456";

        MockTokenProvider mockTokenProvider = new MockTokenProvider();
        mockTokenProvider.addRequestToken(null, mockToken);
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProvider);

        String testScript = getTestScript(CLOUD_PROJECT_NUMBER, "null");
        String actualResponse = runTestScriptAndWaitForResult(testScript);

        Assert.assertEquals(mockToken, actualResponse);

        Assert.assertEquals(1, mockTokenProvider.getCallCount(null));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testTokenRequestAcceptsMissingParameterAsNull() throws Exception {
        String mockToken = "abc123def456";

        MockTokenProvider mockTokenProvider = new MockTokenProvider();
        mockTokenProvider.addRequestToken(null, mockToken);
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProvider);

        String testScript = getTestScript(CLOUD_PROJECT_NUMBER, ""); // don't pass any parameter.
        String actualResponse = runTestScriptAndWaitForResult(testScript);

        Assert.assertEquals(mockToken, actualResponse);

        Assert.assertEquals(1, mockTokenProvider.getCallCount(null));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testInvalidCloudProjectNumbersAreRejected() throws Exception {
        // Only numbers up to 2^53-1 can be represented correctly in JavaScript.
        // Test that numbers larger than this are rejected.
        long largeCloudProjectNumber = 1L << 53;

        String testScript = getTestScript(largeCloudProjectNumber, CONTENT_BINDING_HASH);
        String actualResponse = runTestScriptAndWaitForResult(testScript);

        Assert.assertEquals(
                "TypeError: Failed to execute 'getExperimentalMediaIntegrityTokenProvider' on"
                    + " 'WebView': Failed to read the 'cloudProjectNumber' property from"
                    + " 'GetMediaIntegrityTokenProviderParams': Value is outside the 'unsigned long"
                    + " long' value range.",
                actualResponse);

        Assert.assertEquals(0, mPlatformBridge.getTotalProviderCallCount());
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testErrorsMappedGetTokenProvider() throws Exception {
        mPlatformBridge.addProviderError(
                CLOUD_PROJECT_NUMBER,
                MediaIntegrityApiStatus.ENABLED,
                MediaIntegrityErrorCode.INTERNAL_ERROR);
        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.INTERNAL_ERROR),
                runTestScriptAndWaitForResult(
                        getTestScript(
                                CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH))));

        mPlatformBridge.addProviderError(
                CLOUD_PROJECT_NUMBER,
                MediaIntegrityApiStatus.ENABLED,
                MediaIntegrityErrorCode.NON_RECOVERABLE_ERROR);
        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.NON_RECOVERABLE_ERROR),
                runTestScriptAndWaitForResult(
                        getTestScript(
                                CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH))));

        mPlatformBridge.addProviderError(
                CLOUD_PROJECT_NUMBER,
                MediaIntegrityApiStatus.ENABLED,
                MediaIntegrityErrorCode.API_DISABLED_BY_APPLICATION);
        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.API_DISABLED_BY_APPLICATION),
                runTestScriptAndWaitForResult(
                        getTestScript(
                                CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH))));

        mPlatformBridge.addProviderError(
                CLOUD_PROJECT_NUMBER,
                MediaIntegrityApiStatus.ENABLED,
                MediaIntegrityErrorCode.INVALID_ARGUMENT);
        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.INVALID_ARGUMENT),
                runTestScriptAndWaitForResult(
                        getTestScript(
                                CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH))));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({
        "enable-features=" + AwFeatures.WEBVIEW_MEDIA_INTEGRITY_API_BLINK_EXTENSION
    })
    public void testErrorsMappedRequestToken() throws Exception {
        MockTokenProvider mockTokenProvider = new MockTokenProvider();
        mPlatformBridge.addProviderResponse(
                CLOUD_PROJECT_NUMBER, MediaIntegrityApiStatus.ENABLED, mockTokenProvider);

        mockTokenProvider.addRequestError(
                CONTENT_BINDING_HASH, MediaIntegrityErrorCode.INTERNAL_ERROR);
        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.INTERNAL_ERROR),
                runTestScriptAndWaitForResult(
                        getTestScript(
                                CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH))));

        mockTokenProvider.addRequestError(
                CONTENT_BINDING_HASH, MediaIntegrityErrorCode.NON_RECOVERABLE_ERROR);
        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.NON_RECOVERABLE_ERROR),
                runTestScriptAndWaitForResult(
                        getTestScript(
                                CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH))));

        mockTokenProvider.addRequestError(
                CONTENT_BINDING_HASH, MediaIntegrityErrorCode.INVALID_ARGUMENT);
        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.INVALID_ARGUMENT),
                runTestScriptAndWaitForResult(
                        getTestScript(
                                CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH))));

        mockTokenProvider.addRequestError(
                CONTENT_BINDING_HASH, MediaIntegrityErrorCode.TOKEN_PROVIDER_INVALID);
        Assert.assertEquals(
                getExpectedErrorMessage(MediaIntegrityErrorCode.TOKEN_PROVIDER_INVALID),
                runTestScriptAndWaitForResult(
                        getTestScript(
                                CLOUD_PROJECT_NUMBER, asStringConstant(CONTENT_BINDING_HASH))));
    }

    /**
     * Returns a script that creates a token provider for the passed {@code cloudProjectNumber} and
     * then requests a token with the passed {@code contentBinding}.
     */
    @NonNull
    private String getTestScript(long cloudProjectNumber, @NonNull String contentBinding) {
        return String.format(
                Locale.ENGLISH,
                """
            window.android.webview.getExperimentalMediaIntegrityTokenProvider(
                    {cloudProjectNumber: %d}
                ).then(provider => provider.requestToken(%s))
                .then(token => testListener.postMessage(token))
                .catch(e => {
                  if (e.mediaIntegrityErrorName !== undefined
                      && e.mediaIntegrityErrorName !== null) {
                    testListener.postMessage(e.mediaIntegrityErrorName);
                  } else {
                    testListener.postMessage("" + e); // Convert error to string for matching.
                  }
                });
            """,
                cloudProjectNumber,
                contentBinding);
    }

    /** Make the passed string a JS String constant by wrapping in {@code ""}. */
    @NonNull
    private String asStringConstant(@NonNull String value) {
        return String.format("\"%s\"", value);
    }

    @NonNull
    private String runTestScriptAndWaitForResult(@NonNull String script) throws Exception {
        int callCount = mMessageListener.getCurrentCallbackCount();
        mRule.executeJavaScriptAndWaitForResult(mAwContents, mContentsClient, script);
        return mMessageListener.waitForResponse(callCount);
    }

    /** Assert the passed in JavaScript expression evaluates as "truthy". */
    private void assertJsTruthy(@NonNull String jsExpression) throws Exception {
        String result =
                mRule.executeJavaScriptAndWaitForResult(
                        mAwContents,
                        mContentsClient,
                        "!!(" + jsExpression + ") ? \"true\" : \"false\";");
        Assert.assertEquals("\"true\"", result);
    }

    @NonNull
    private String getExpectedErrorMessage(@MediaIntegrityErrorCode int code) {
        return switch (code) {
            case MediaIntegrityErrorCode.INTERNAL_ERROR -> "internal-error";
            case MediaIntegrityErrorCode.NON_RECOVERABLE_ERROR -> "non-recoverable-error";
            case MediaIntegrityErrorCode
                    .API_DISABLED_BY_APPLICATION -> "api-disabled-by-application";
            case MediaIntegrityErrorCode.INVALID_ARGUMENT -> "invalid-argument";
            case MediaIntegrityErrorCode.TOKEN_PROVIDER_INVALID -> "token-provider-invalid";
            default -> {
                Assert.fail("Invalid MediaIntegrityErrorCode: " + code);
                yield "";
            }
        };
    }

    private void assertNotExposed() throws Throwable {
        // Only the getExperimentalMediaIntegrityTokenProvider part of the API gets hidden or not.
        assertJsTruthy(
                "typeof(android.webview.getExperimentalMediaIntegrityTokenProvider) ==="
                        + " 'undefined'");
    }

    /** WebMessageListener that allows us to get async JS responses back for verification. */
    private static class TestWebMessageListener implements WebMessageListener {

        private final CallbackHelper mCallbackHelper = new CallbackHelper();

        private final Queue<String> mResponseQueue = new ArrayDeque<>();

        @Override
        public void onPostMessage(
                MessagePayload payload,
                Uri topLevelOrigin,
                Uri sourceOrigin,
                boolean isMainFrame,
                JsReplyProxy jsReplyProxy,
                MessagePort[] ports) {
            synchronized (mResponseQueue) {
                mResponseQueue.add(payload.getAsString());
            }
            mCallbackHelper.notifyCalled();
        }

        public int getCurrentCallbackCount() {
            return mCallbackHelper.getCallCount();
        }

        @NonNull
        public String waitForResponse(int currentCallCount) throws TimeoutException {
            mCallbackHelper.waitForCallback(currentCallCount);
            synchronized (mResponseQueue) {
                return mResponseQueue.poll();
            }
        }

        @NonNull
        public List<String> waitForMultipleResponses(int currentCallCount, int responsesToWaitFor)
                throws TimeoutException {
            mCallbackHelper.waitForCallback(currentCallCount, responsesToWaitFor);
            List<String> results = new ArrayList<>();
            synchronized (mResponseQueue) {
                for (int i = 0; i < responsesToWaitFor; i++) {
                    results.add(mResponseQueue.poll());
                }
            }
            return results;
        }
    }

    /** Token provider where responses can be queued for testing. */
    private static class MockTokenProvider implements MediaIntegrityProvider {

        private final Map<String, Queue<Object>> mResponses = new HashMap<>();
        private final Map<String, Integer> mCallCounts = new HashMap<>();

        private int mCallCount;

        public int getTotalCallCount() {
            return mCallCount;
        }

        public int getCallCount(@Nullable String contentBinding) {
            //noinspection DataFlowIssue
            return mCallCounts.getOrDefault(contentBinding, 0);
        }

        public void addRequestToken(@Nullable String contentBinding, @NonNull String token) {
            mResponses.computeIfAbsent(contentBinding, s -> new LinkedList<>()).offer(token);
        }

        public void addRequestError(
                @Nullable String contentBinding, @MediaIntegrityErrorCode int errorCode) {
            mResponses
                    .computeIfAbsent(contentBinding, s -> new LinkedList<>())
                    .offer(new MediaIntegrityErrorWrapper(errorCode));
        }

        @Override
        public void requestToken2(
                @Nullable String contentBinding,
                @NonNull ValueOrErrorCallback<String, MediaIntegrityErrorWrapper> callback) {
            mCallCount++;
            Queue<Object> responseQueue = mResponses.get(contentBinding);
            mCallCounts.compute(contentBinding, (s, count) -> count == null ? 1 : count + 1);
            if (responseQueue != null) {
                Object response = responseQueue.poll();
                if (response instanceof String token) {
                    callback.onResult(token);
                    return;
                }
                if (response instanceof MediaIntegrityErrorWrapper error) {
                    callback.onError(error);
                    return;
                }
            }
            Assert.fail("Test was configured without any handlers for input: " + contentBinding);
        }
    }

    /** PlatformServiceBridge where MediaIntegrityProvider responses can be queued. */
    private static class MockPlatformServiceBridge extends PlatformServiceBridgeImpl {

        private static class CallKey {

            long mCloudProjectNumber;
            int mApiStatus;

            public CallKey(long cloudProjectNumber, int apiStatus) {
                mCloudProjectNumber = cloudProjectNumber;
                mApiStatus = apiStatus;
            }

            @Override
            public int hashCode() {
                return Objects.hash(mCloudProjectNumber, mApiStatus);
            }

            @Override
            public boolean equals(@Nullable Object obj) {
                if (obj instanceof CallKey key) {
                    return mCloudProjectNumber == key.mCloudProjectNumber
                            && this.mApiStatus == key.mApiStatus;
                }
                return false;
            }
        }

        int mProviderCallCount;
        private final Map<CallKey, Queue<Object>> mResponses = new HashMap<>();
        private final Map<CallKey, Integer> mCallCounts = new HashMap<>();

        public void addProviderResponse(
                long cloudProjectNumber,
                @MediaIntegrityApiStatus int apiStatus,
                MediaIntegrityProvider provider) {
            CallKey key = new CallKey(cloudProjectNumber, apiStatus);
            mResponses.computeIfAbsent(key, k -> new LinkedList<>()).offer(provider);
        }

        public void addProviderError(
                long cloudProjectNumber,
                @MediaIntegrityApiStatus int apiStatus,
                @MediaIntegrityErrorCode int errorCode) {
            CallKey key = new CallKey(cloudProjectNumber, apiStatus);
            mResponses
                    .computeIfAbsent(key, k -> new LinkedList<>())
                    .offer(new MediaIntegrityErrorWrapper(errorCode));
        }

        public int getProviderCallCount(
                long cloudProjectNumber, @MediaIntegrityApiStatus int apiStatus) {
            //noinspection DataFlowIssue
            return mCallCounts.getOrDefault(new CallKey(cloudProjectNumber, apiStatus), 0);
        }

        public int getTotalProviderCallCount() {
            return mProviderCallCount;
        }

        @Override
        public void getMediaIntegrityProvider2(
                long cloudProjectNumber,
                @MediaIntegrityApiStatus int apiStatus,
                ValueOrErrorCallback<MediaIntegrityProvider, MediaIntegrityErrorWrapper> callback) {
            CallKey key = new CallKey(cloudProjectNumber, apiStatus);
            Queue<Object> responseQueue = mResponses.get(key);
            mCallCounts.compute(key, (callKey, counts) -> counts == null ? 1 : counts + 1);
            mProviderCallCount += 1;

            if (responseQueue != null) {
                Object response = responseQueue.poll();
                if (response instanceof MediaIntegrityProvider provider) {
                    callback.onResult(provider);
                    return;
                }
                if (response instanceof MediaIntegrityErrorWrapper error) {
                    callback.onError(error);
                    return;
                }
            }
            Assert.fail(
                    "Test was configured without any handlers for input: "
                            + cloudProjectNumber
                            + ", "
                            + apiStatus);
        }
    }
}