chromium/chrome/android/javatests/src/org/chromium/chrome/browser/notifications/NotificationPlatformBridgeTest.java

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

package org.chromium.chrome.browser.notifications;

import static androidx.test.espresso.matcher.ViewMatchers.assertThat;

import static org.chromium.components.content_settings.PrefNames.NOTIFICATIONS_VIBRATE_ENABLED;

import android.app.Notification;
import android.app.PendingIntent;
import android.app.RemoteInput;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;

import androidx.annotation.RequiresApi;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.LargeTest;
import androidx.test.filters.MediumTest;

import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.chromium.base.ThreadUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.DisabledTest;
import org.chromium.base.test.util.Feature;
import org.chromium.base.test.util.Features;
import org.chromium.base.test.util.MinAndroidSdkLevel;
import org.chromium.base.test.util.UserActionTester;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.permissions.PermissionTestRule;
import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.R;
import org.chromium.chrome.test.util.ChromeTabUtils;
import org.chromium.chrome.test.util.browser.TabTitleObserver;
import org.chromium.components.browser_ui.notifications.MockNotificationManagerProxy.NotificationEntry;
import org.chromium.components.browser_ui.widget.RoundedIconGenerator;
import org.chromium.components.content_settings.ContentSettingValues;
import org.chromium.components.permissions.PermissionDialogController;
import org.chromium.components.site_engagement.SiteEngagementService;
import org.chromium.components.url_formatter.SchemeDisplay;
import org.chromium.components.url_formatter.UrlFormatter;
import org.chromium.components.user_prefs.UserPrefs;
import org.chromium.url.GURL;

import java.net.URL;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.TimeoutException;

/**
 * Instrumentation tests for the Notification Bridge.
 *
 * <p>Web Notifications are only supported on Android JellyBean and beyond.
 */
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE})
public class NotificationPlatformBridgeTest {
    @Rule public PermissionTestRule mPermissionTestRule = new PermissionTestRule();

    @Rule public NotificationTestRule mNotificationTestRule = new NotificationTestRule();

    private static final String NOTIFICATION_TEST_PAGE =
            "/chrome/test/data/notifications/android_test.html";
    private static final int TITLE_UPDATE_TIMEOUT_SECONDS = (int) 5L;

    @Before
    public void setUp() {
        SiteEngagementService.setParamValuesForTesting();
        mNotificationTestRule.loadUrl(mPermissionTestRule.getURL(NOTIFICATION_TEST_PAGE));
        mPermissionTestRule.setActivity(mNotificationTestRule.getActivity());
    }

    @SuppressWarnings("MissingFail")
    private void waitForTitle(String expectedTitle) {
        Tab tab = mNotificationTestRule.getActivity().getActivityTab();
        TabTitleObserver titleObserver = new TabTitleObserver(tab, expectedTitle);
        try {
            titleObserver.waitForTitleUpdate(TITLE_UPDATE_TIMEOUT_SECONDS);

        } catch (TimeoutException e) {
            // The title is not as expected, this assertion neatly logs what the difference is.
            Assert.assertEquals(expectedTitle, ChromeTabUtils.getTitleOnUiThread(tab));
        }
    }

    private void checkThatShowNotificationIsDenied() throws Exception {
        showNotification("MyNotification", "{}");
        waitForTitle(
                "TypeError: Failed to execute 'showNotification' on 'ServiceWorkerRegistration': "
                        + "No notification permission has been granted for this origin.");

        // Ideally we'd wait a little here, but it's hard to wait for things that shouldn't happen.
        Assert.assertTrue(mNotificationTestRule.getNotificationEntries().isEmpty());
    }

    private double getEngagementScoreBlocking() {
        // TODO (https://crbug.com/1063807):  Add incognito mode tests.
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        SiteEngagementService.getForBrowserContext(
                                        ProfileManager.getLastUsedRegularProfile())
                                .getScore(mPermissionTestRule.getOrigin()));
    }

    /**
     * Verifies that notifcations cannot be shown without permission, and that dismissing or denying
     * the infobar works correctly.
     */
    @LargeTest
    @Feature({"Browser", "Notifications"})
    @Test
    @DisabledTest(message = "https://crbug.com/1435133")
    public void testPermissionDenied() throws Exception {
        // Notifications permission should initially be prompt, and showing should fail.
        Assert.assertEquals("\"default\"", runJavaScript("Notification.permission"));
        checkThatShowNotificationIsDenied();

        PermissionTestRule.PermissionUpdateWaiter updateWaiter =
                new PermissionTestRule.PermissionUpdateWaiter(
                        "denied: ", mNotificationTestRule.getActivity());

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mNotificationTestRule.getActivity().getActivityTab().addObserver(updateWaiter);
                });

        mPermissionTestRule.runDenyTest(
                updateWaiter,
                NOTIFICATION_TEST_PAGE,
                "Notification.requestPermission(addCountAndSendToTest)",
                1,
                false,
                true);

        // This should have caused notifications permission to become denied.
        Assert.assertEquals("\"denied\"", runJavaScript("Notification.permission"));
        checkThatShowNotificationIsDenied();

        // Reload page to ensure the block is persisted.
        mNotificationTestRule.loadUrl(mPermissionTestRule.getURL(NOTIFICATION_TEST_PAGE));

        // Notification.requestPermission() should immediately pass denied to the callback without
        // showing a dialog.
        runJavaScript("Notification.requestPermission(sendToTest)");
        waitForTitle("denied");
        Assert.assertFalse(PermissionDialogController.getInstance().isDialogShownForTest());

        // Notifications permission should still be denied.
        Assert.assertEquals("\"denied\"", runJavaScript("Notification.permission"));
        checkThatShowNotificationIsDenied();
    }

    /** Verifies granting permission via the infobar. */
    @MediumTest
    @Feature({"Browser", "Notifications"})
    @Test
    @DisabledTest(message = "https://crbug.com/1435133")
    public void testPermissionGranted() throws Exception {
        // Notifications permission should initially be prompt, and showing should fail.
        Assert.assertEquals("\"default\"", runJavaScript("Notification.permission"));
        checkThatShowNotificationIsDenied();

        PermissionTestRule.PermissionUpdateWaiter updateWaiter =
                new PermissionTestRule.PermissionUpdateWaiter(
                        "granted: ", mNotificationTestRule.getActivity());

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mNotificationTestRule.getActivity().getActivityTab().addObserver(updateWaiter);
                });

        mPermissionTestRule.runAllowTest(
                updateWaiter,
                NOTIFICATION_TEST_PAGE,
                "Notification.requestPermission(addCountAndSendToTest)",
                1,
                false,
                true);

        // Reload page to ensure the grant is persisted.
        mNotificationTestRule.loadUrl(mPermissionTestRule.getURL(NOTIFICATION_TEST_PAGE));

        // Notifications permission should now be granted, and showing should succeed.
        Assert.assertEquals("\"granted\"", runJavaScript("Notification.permission"));
        showAndGetNotification("MyNotification", "{}");
    }

    /**
     * Verifies that the intended default properties of a notification will indeed be set on the
     * Notification object that will be send to Android.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testDefaultNotificationProperties() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());
        Context context = ApplicationProvider.getApplicationContext();

        Notification notification = showAndGetNotification("MyNotification", "{body: 'Hello'}");

        String expectedOrigin =
                UrlFormatter.formatUrlForSecurityDisplay(
                        mPermissionTestRule.getOrigin(), SchemeDisplay.OMIT_HTTP_AND_HTTPS);

        // Validate the contents of the notification.
        Assert.assertEquals("MyNotification", NotificationTestUtil.getExtraTitle(notification));
        Assert.assertEquals("Hello", NotificationTestUtil.getExtraText(notification));
        Assert.assertEquals(expectedOrigin, NotificationTestUtil.getExtraSubText(notification));

        // Verify that the ticker text contains the notification's title and body.
        String tickerText = notification.tickerText.toString();

        Assert.assertTrue(tickerText.contains("MyNotification"));
        Assert.assertTrue(tickerText.contains("Hello"));

        // Verify the public version of the notification contains the notification's origin,
        // and that the body text has been replaced.
        Assert.assertNotNull(notification.publicVersion);
        Assert.assertEquals(
                context.getString(R.string.notification_hidden_text),
                NotificationTestUtil.getExtraText(notification.publicVersion));
        // On N+, origin should be set as the subtext of the public notification.
        Assert.assertEquals(
                expectedOrigin, NotificationTestUtil.getExtraSubText(notification.publicVersion));

        // Verify that the notification's timestamp is set in the past 60 seconds. This number has
        // no significance, but needs to be high enough to not cause flakiness as it's set by the
        // renderer process on notification creation.
        Assert.assertTrue(Math.abs(System.currentTimeMillis() - notification.when) < 60 * 1000);

        boolean timestampIsShown = NotificationTestUtil.getExtraShownWhen(notification);
        Assert.assertTrue("Timestamp should be shown", timestampIsShown);

        Assert.assertNotNull(
                NotificationTestUtil.getLargeIconFromNotification(context, notification));

        // Validate the notification's behavior. On Android O+ the defaults are ignored as vibrate
        // and silent moved to the notification channel. The silent flag is achieved by using a
        // group alert summary.
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            Assert.assertEquals(0, notification.defaults);
            Assert.assertEquals(Notification.GROUP_ALERT_ALL, notification.getGroupAlertBehavior());
        } else {
            Assert.assertEquals(Notification.DEFAULT_ALL, notification.defaults);
        }
        Assert.assertEquals(Notification.PRIORITY_DEFAULT, notification.priority);
    }

    /**
     * Verifies that specifying a notification action with type: 'text' results in a notification
     * with a remote input on the action.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testShowNotificationWithTextAction() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());

        Notification notification =
                showAndGetNotification(
                        "MyNotification",
                        "{ "
                                + " actions: [{action: 'myAction', title: 'reply', type: 'text',"
                                + " placeholder: 'hi' }]}");

        // The specified action should be present, as well as a default settings action.
        Assert.assertEquals(2, notification.actions.length);

        Notification.Action action = notification.actions[0];
        Assert.assertEquals("reply", action.title);
        Assert.assertNotNull(notification.actions[0].getRemoteInputs());
        Assert.assertEquals(1, action.getRemoteInputs().length);
        Assert.assertEquals("hi", action.getRemoteInputs()[0].getLabel());
    }

    /**
     * Verifies that setting a reply on the remote input of a notification action with type 'text'
     * and triggering the action's intent causes the same reply to be received in the subsequent
     * notificationclick event on the service worker. Verifies that site engagement is incremented
     * appropriately.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testReplyToNotification() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());
        Context context = ApplicationProvider.getApplicationContext();

        UserActionTester actionTester = new UserActionTester();

        // +0.5 engagement from navigating to the test page.
        Assert.assertEquals(0.5, getEngagementScoreBlocking(), 0);
        runJavaScript("SetupReplyForwardingForTests();");
        Notification notification =
                showAndGetNotification(
                        "MyNotification",
                        "{ "
                                + " actions: [{action: 'myAction', title: 'reply', type: 'text'}],"
                                + " data: 'ACTION_REPLY'}");

        // Check the action is present with a remote input attached.
        Notification.Action action = notification.actions[0];
        Assert.assertEquals("reply", action.title);
        RemoteInput[] remoteInputs = action.getRemoteInputs();
        Assert.assertNotNull(remoteInputs);

        // Set a reply using the action's remote input key and send it on the intent.
        sendIntentWithRemoteInput(
                context,
                action.actionIntent,
                remoteInputs,
                remoteInputs[0].getResultKey(),
                /* reply= */ "My Reply");

        // Check reply was received by the service worker (see android_test_worker.js).
        // Expect +1 engagement from interacting with the notification.
        waitForTitle("reply: My Reply");
        Assert.assertEquals(1.5, getEngagementScoreBlocking(), 0);

        // Replies are always delivered to an action button.
        assertThat(
                actionTester.toString(),
                getNotificationActions(actionTester),
                Matchers.hasItems(
                        "Notifications.Persistent.Shown",
                        "Notifications.Persistent.ClickedActionButton"));
    }

    /**
     * Verifies that setting an empty reply on the remote input of a notification action with type
     * 'text' and triggering the action's intent causes an empty reply string to be received in the
     * subsequent notificationclick event on the service worker. Verifies that site engagement is
     * incremented appropriately.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testReplyToNotificationWithEmptyReply() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());
        Context context = ApplicationProvider.getApplicationContext();

        // +0.5 engagement from navigating to the test page.
        Assert.assertEquals(0.5, getEngagementScoreBlocking(), 0);
        runJavaScript("SetupReplyForwardingForTests();");
        Notification notification =
                showAndGetNotification(
                        "MyNotification",
                        "{ "
                                + " actions: [{action: 'myAction', title: 'reply', type: 'text'}],"
                                + " data: 'ACTION_REPLY'}");

        // Check the action is present with a remote input attached.
        Notification.Action action = notification.actions[0];
        Assert.assertEquals("reply", action.title);
        RemoteInput[] remoteInputs = action.getRemoteInputs();
        Assert.assertNotNull(remoteInputs);

        // Set a reply using the action's remote input key and send it on the intent.
        sendIntentWithRemoteInput(
                context,
                action.actionIntent,
                remoteInputs,
                remoteInputs[0].getResultKey(),
                /* reply= */ "");

        // Check empty reply was received by the service worker (see android_test_worker.js).
        // Expect +1 engagement from interacting with the notification.
        waitForTitle("reply:");
        Assert.assertEquals(1.5, getEngagementScoreBlocking(), 0);
    }

    private static void sendIntentWithRemoteInput(
            Context context,
            PendingIntent pendingIntent,
            RemoteInput[] remoteInputs,
            String resultKey,
            String reply)
            throws PendingIntent.CanceledException {
        Bundle results = new Bundle();
        results.putString(resultKey, reply);
        Intent fillInIntent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
        RemoteInput.addResultsToIntent(remoteInputs, fillInIntent, results);

        // Send the pending intent filled in with the additional information from the new
        // intent.
        pendingIntent.send(context, /* code= */ 0, fillInIntent);
    }

    /**
     * Verifies that *not* setting a reply on the remote input of a notification action with type
     * 'text' and triggering the action's intent causes a null reply to be received in the
     * subsequent notificationclick event on the service worker. Verifies that site engagement is
     * incremented appropriately.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testReplyToNotificationWithNoRemoteInput() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());

        // +0.5 engagement from navigating to the test page.
        Assert.assertEquals(0.5, getEngagementScoreBlocking(), 0);
        runJavaScript("SetupReplyForwardingForTests();");
        Notification notification =
                showAndGetNotification(
                        "MyNotification",
                        "{ "
                                + " actions: [{action: 'myAction', title: 'reply', type: 'text'}],"
                                + " data: 'ACTION_REPLY'}");

        Assert.assertEquals("reply", notification.actions[0].title);
        notification.actions[0].actionIntent.send();

        // Check reply was received by the service worker (see android_test_worker.js).
        // Expect +1 engagement from interacting with the notification.
        waitForTitle("reply: null");
        Assert.assertEquals(1.5, getEngagementScoreBlocking(), 0);
    }

    /** Verifies that the ONLY_ALERT_ONCE flag is not set when renotify is true. */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testNotificationRenotifyProperty() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());

        Notification notification =
                showAndGetNotification("MyNotification", "{ tag: 'myTag', renotify: true }");

        Assert.assertEquals(0, notification.flags & Notification.FLAG_ONLY_ALERT_ONCE);
    }

    /**
     * Verifies that notifications created with the "silent" flag do not inherit system defaults in
     * regards to their sound, vibration and light indicators.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testNotificationSilentProperty() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());

        Notification notification = showAndGetNotification("MyNotification", "{ silent: true }");

        // Zero indicates that no defaults should be inherited from the system.
        Assert.assertEquals(0, notification.defaults);

        // On Android O+ the defaults are ignored as vibrate and silent moved to the notification
        // channel. The silent flag is achieved by using a group alert summary.
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            Assert.assertEquals(
                    Notification.GROUP_ALERT_SUMMARY, notification.getGroupAlertBehavior());
        }
    }

    private void verifyVibrationNotRequestedWhenDisabledInPrefs(String notificationOptions)
            throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());

        // Disable notification vibration in preferences.
        ThreadUtils.runOnUiThreadBlocking(
                () ->
                        UserPrefs.get(ProfileManager.getLastUsedRegularProfile())
                                .setBoolean(NOTIFICATIONS_VIBRATE_ENABLED, false));

        Notification notification = showAndGetNotification("MyNotification", notificationOptions);

        // On Android O+ the defaults are ignored as vibrate and silent moved to the notification
        // channel.
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            Assert.assertEquals(0, notification.defaults);
        } else {
            // Vibration should not be in the defaults.
            Assert.assertEquals(
                    Notification.DEFAULT_ALL & ~Notification.DEFAULT_VIBRATE,
                    notification.defaults);

            // There should be a custom no-op vibration pattern.
            Assert.assertEquals(1, notification.vibrate.length);
            Assert.assertEquals(0L, notification.vibrate[0]);
        }
    }

    /**
     * Verifies that when notification vibration is disabled in preferences and no custom pattern is
     * specified, no vibration is requested from the framework.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testNotificationVibratePreferenceDisabledDefault() throws Exception {
        verifyVibrationNotRequestedWhenDisabledInPrefs("{}");
    }

    /**
     * Verifies that when notification vibration is disabled in preferences and a custom pattern is
     * specified, no vibration is requested from the framework.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testNotificationVibratePreferenceDisabledCustomPattern() throws Exception {
        verifyVibrationNotRequestedWhenDisabledInPrefs("{ vibrate: 42 }");
    }

    /**
     * Verifies that by default the notification vibration preference is enabled, and a custom
     * pattern is passed along.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testNotificationVibrateCustomPattern() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());

        // By default, vibration is enabled in notifications.
        ThreadUtils.runOnUiThreadBlocking(
                () ->
                        Assert.assertTrue(
                                UserPrefs.get(ProfileManager.getLastUsedRegularProfile())
                                        .getBoolean(NOTIFICATIONS_VIBRATE_ENABLED)));

        Notification notification = showAndGetNotification("MyNotification", "{ vibrate: 42 }");

        // On Android O+ the defaults are ignored as vibrate and silent moved to the notification
        // channel.
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            Assert.assertEquals(0, notification.defaults);
        } else {
            // Vibration should not be in the defaults, a custom pattern was provided.
            Assert.assertEquals(
                    Notification.DEFAULT_ALL & ~Notification.DEFAULT_VIBRATE,
                    notification.defaults);

            // The custom pattern should have been passed along.
            Assert.assertEquals(2, notification.vibrate.length);
            Assert.assertEquals(0L, notification.vibrate[0]);
            Assert.assertEquals(42L, notification.vibrate[1]);
        }
    }

    /**
     * Verifies that on Android M+, notifications which specify a badge will have that icon fetched
     * and included as the small icon in the notification and public version. If the test target is
     * L or below, verifies the small icon (and public small icon on L) is the expected chrome logo.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    @SuppressWarnings("UseNetworkAnnotations")
    public void testShowNotificationWithBadge() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());

        Notification notification =
                showAndGetNotification("MyNotification", "{badge: 'badge.png'}");

        Assert.assertEquals("MyNotification", NotificationTestUtil.getExtraTitle(notification));

        Context context = ApplicationProvider.getApplicationContext();
        Bitmap smallIcon = NotificationTestUtil.getSmallIconFromNotification(context, notification);
        Assert.assertNotNull(smallIcon);

        // Custom badges are only supported on M+.
        // 1. Check the notification badge.
        URL badgeUrl =
                new URL(mPermissionTestRule.getURL("/chrome/test/data/notifications/badge.png"));
        Bitmap bitmap = BitmapFactory.decodeStream(badgeUrl.openStream());
        Bitmap expected = bitmap.copy(bitmap.getConfig(), true);
        NotificationBuilderBase.applyWhiteOverlayToBitmap(expected);
        Assert.assertTrue(expected.sameAs(smallIcon));

        // 2. Check the public notification badge.
        Assert.assertNotNull(notification.publicVersion);
        Bitmap publicSmallIcon =
                NotificationTestUtil.getSmallIconFromNotification(
                        context, notification.publicVersion);
        Assert.assertNotNull(publicSmallIcon);
        Assert.assertEquals(expected.getWidth(), publicSmallIcon.getWidth());
        Assert.assertEquals(expected.getHeight(), publicSmallIcon.getHeight());
        Assert.assertTrue(expected.sameAs(publicSmallIcon));
    }

    /**
     * Verifies that notifications which specify an icon will have that icon fetched, converted into
     * a Bitmap and included as the large icon in the notification.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testShowNotificationWithIcon() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());

        Notification notification = showAndGetNotification("MyNotification", "{icon: 'red.png'}");

        Assert.assertEquals("MyNotification", NotificationTestUtil.getExtraTitle(notification));

        Context context = ApplicationProvider.getApplicationContext();
        Bitmap largeIcon = NotificationTestUtil.getLargeIconFromNotification(context, notification);
        Assert.assertNotNull(largeIcon);
        Assert.assertEquals(Color.RED, largeIcon.getPixel(0, 0));
    }

    /**
     * Verifies that notifications which don't specify an icon will get an automatically generated
     * icon based on their origin. The size of these icons are dependent on the resolution of the
     * device the test is being ran on, so we create it again in order to compare.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testShowNotificationWithoutIcon() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());

        Notification notification = showAndGetNotification("NoIconNotification", "{}");

        Assert.assertEquals("NoIconNotification", NotificationTestUtil.getExtraTitle(notification));

        Context context = ApplicationProvider.getApplicationContext();
        Assert.assertNotNull(
                NotificationTestUtil.getLargeIconFromNotification(context, notification));

        NotificationPlatformBridge notificationBridge =
                NotificationPlatformBridge.getInstanceForTests();
        Assert.assertNotNull(notificationBridge);

        // Create a second rounded icon for the test's origin, and compare its dimensions against
        // those of the icon associated to the notification itself.
        RoundedIconGenerator generator =
                NotificationBuilderBase.createIconGenerator(context.getResources());

        Bitmap generatedIcon =
                generator.generateIconForUrl(new GURL(mPermissionTestRule.getOrigin()));
        Assert.assertNotNull(generatedIcon);
        // Starts from Android O MR1, large icon can be downscaled by Android platform code.
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
            Assert.assertTrue(
                    generatedIcon.sameAs(
                            NotificationTestUtil.getLargeIconFromNotification(
                                    context, notification)));
        }
    }

    /*
     * Verifies that starting the PendingIntent stored as the notification's content intent will
     * start up the associated Service Worker, where the JavaScript code will close the notification
     * by calling event.notification.close().
     */
    @Test
    @LargeTest
    @Feature({"Browser", "Notifications"})
    public void testNotificationContentIntentClosesNotification() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());
        // +0.5 engagement from navigating to the test page.
        Assert.assertEquals(0.5, getEngagementScoreBlocking(), 0);

        UserActionTester actionTester = new UserActionTester();

        Notification notification = showAndGetNotification("MyNotification", "{}");

        // Sending the PendingIntent resembles activating the notification.
        Assert.assertNotNull(notification.contentIntent);
        notification.contentIntent.send();

        // The Service Worker will close the notification upon receiving the notificationclick
        // event. This will eventually bubble up to a call to cancel() in the NotificationManager.
        // Expect +1 engagement from interacting with the notification.
        mNotificationTestRule.waitForNotificationManagerMutation();
        Assert.assertTrue(mNotificationTestRule.getNotificationEntries().isEmpty());
        Assert.assertEquals(1.5, getEngagementScoreBlocking(), 0);

        // This metric only applies on N+, where we schedule a job to handle the click.
        Assert.assertEquals(
                1,
                RecordHistogram.getHistogramTotalCountForTesting(
                        "Notifications.Android.JobStartDelay"));

        // Clicking on a notification should record the right user metrics.
        assertThat(
                actionTester.toString(),
                getNotificationActions(actionTester),
                Matchers.hasItems(
                        "Notifications.Persistent.Shown", "Notifications.Persistent.Clicked"));
        Assert.assertEquals(
                1,
                RecordHistogram.getHistogramTotalCountForTesting(
                        "Notifications.AppNotificationStatus"));
    }

    /**
     * Verifies that starting the PendingIntent stored as the notification's content intent will
     * start up the associated Service Worker, where the JavaScript code will create a new tab for
     * displaying the notification's event to the user.
     */
    @Test
    @LargeTest
    @Feature({"Browser", "Notifications"})
    public void testNotificationContentIntentCreatesTab() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());

        Assert.assertEquals(
                "Expected the notification test page to be the sole tab in the current model",
                1,
                mNotificationTestRule.getActivity().getCurrentTabModel().getCount());

        Notification notification =
                showAndGetNotification("MyNotification", "{ data: 'ACTION_CREATE_TAB' }");

        // Sending the PendingIntent resembles activating the notification.
        Assert.assertNotNull(notification.contentIntent);
        notification.contentIntent.send();

        // The Service Worker, upon receiving the notificationclick event, will create a new tab
        // after which it closes the notification.
        mNotificationTestRule.waitForNotificationManagerMutation();
        Assert.assertTrue(mNotificationTestRule.getNotificationEntries().isEmpty());

        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    Criteria.checkThat(
                            "Expected a new tab to be created",
                            mNotificationTestRule.getActivity().getCurrentTabModel().getCount(),
                            Matchers.is(2));
                });
        // This metric only applies on N+, where we schedule a job to handle the click.
        Assert.assertEquals(
                1,
                RecordHistogram.getHistogramTotalCountForTesting(
                        "Notifications.Android.JobStartDelay"));
    }

    /**
     * Verifies that activating the PendingIntent associated with the "Unsubscribe" button shows the
     * `provisionally unsubscribed` notification and suspends all existing notifications, and then,
     * clicking "Okay" commits this and revokes the notification permission.
     *
     * <p>One-tap Unsubscribe is supported on Android P and later.
     */
    @Test
    @LargeTest
    @Feature({"Browser", "Notifications"})
    @Features.EnableFeatures(ChromeFeatureList.NOTIFICATION_ONE_TAP_UNSUBSCRIBE)
    @MinAndroidSdkLevel(Build.VERSION_CODES.P)
    public void testNotificationProvisionalUnsubscribeAndCommit() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());
        Assert.assertEquals("\"granted\"", runJavaScript("Notification.permission"));

        Notification notification1 = showAndGetNotification("Notification1", "{}");
        showNotification("Notification2", "{}");
        mNotificationTestRule.waitForNotificationCount(2);

        // Click the "Unsubscribe" button.
        Assert.assertEquals(1, notification1.actions.length);
        PendingIntent unsubscribeIntent = notification1.actions[0].actionIntent;
        Assert.assertNotNull(unsubscribeIntent);
        unsubscribeIntent.send();

        // Wait for the two notifications to be collapsed and the `provisionally unsubscribed`
        // notification to appear.
        mNotificationTestRule.waitForNotificationCount(1);

        // Click the "Okay" button to commit. This is the second button.
        Notification provisionallyUnsubscribedNotification =
                mNotificationTestRule.getNotificationEntries().get(0).getNotification();
        Assert.assertEquals(2, provisionallyUnsubscribedNotification.actions.length);
        PendingIntent commitIntent = provisionallyUnsubscribedNotification.actions[1].actionIntent;
        Assert.assertNotNull(commitIntent);
        commitIntent.send();

        // Wait for the `provisionally unsubscribed` notification to disappear.
        mNotificationTestRule.waitForNotificationCount(0);

        // This should have caused notifications permission to become reset.
        Assert.assertEquals("\"default\"", runJavaScript("Notification.permission"));
        checkThatShowNotificationIsDenied();
    }

    /**
     * Verifies that activating the PendingIntent associated with the "Unsubscribe" button shows the
     * `provisionally unsubscribed` notification and suspends all existing notifications, and then,
     * clicking "Undo" reverts this and does not revoke the notification permission.
     *
     * <p>This also verifies that the icon image, which is stored and then loaded from the native
     * `NotificationDatabase`, properly survives this journey.
     *
     * <p>One-tap Unsubscribe is supported on Android P and later.
     */
    @Test
    @LargeTest
    @Feature({"Browser", "Notifications"})
    @Features.EnableFeatures(ChromeFeatureList.NOTIFICATION_ONE_TAP_UNSUBSCRIBE)
    @MinAndroidSdkLevel(Build.VERSION_CODES.P)
    public void testNotificationProvisionalUnsubscribeAndUndo() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());
        Assert.assertEquals("\"granted\"", runJavaScript("Notification.permission"));

        Notification notification1 = showAndGetNotification("Notification1", "{icon: 'red.png'}");
        showNotification("Notification2", "{}");
        mNotificationTestRule.waitForNotificationCount(2);

        // Verify that the origin notifications will play sound/vibration.
        var originalSBNotifications = mNotificationTestRule.getNotificationEntries();
        Assert.assertEquals(
                Notification.GROUP_ALERT_ALL,
                originalSBNotifications.get(0).getNotification().getGroupAlertBehavior());
        Assert.assertEquals(
                Notification.GROUP_ALERT_ALL,
                originalSBNotifications.get(1).getNotification().getGroupAlertBehavior());

        // Click the "Unsubscribe" button.
        Assert.assertEquals(1, notification1.actions.length);
        PendingIntent unsubscribeIntent = notification1.actions[0].actionIntent;
        Assert.assertNotNull(unsubscribeIntent);
        unsubscribeIntent.send();

        // Wait for the two notifications to be collapsed and the `provisionally unsubscribed`
        // notification to appear.
        mNotificationTestRule.waitForNotificationCount(1);

        // Click the "Undo" button to revert. This is the first button.
        Notification provisionallyUnsubscribedNotification =
                mNotificationTestRule.getNotificationEntries().get(0).getNotification();
        Assert.assertEquals(2, provisionallyUnsubscribedNotification.actions.length);
        PendingIntent undoIntent = provisionallyUnsubscribedNotification.actions[0].actionIntent;
        Assert.assertNotNull(undoIntent);
        undoIntent.send();

        // Wait for the `provisionally unsubscribed` notification to disappear and the two
        // notifications to be restored.
        mNotificationTestRule.waitForNotificationCount(2);

        // Verify the icon is restored correctly.
        Context context = ApplicationProvider.getApplicationContext();
        var restoredSBNotifications = mNotificationTestRule.getNotificationEntries();
        Bitmap largeIcon =
                NotificationTestUtil.getLargeIconFromNotification(
                        context, restoredSBNotifications.get(0).getNotification());
        Assert.assertNotNull(largeIcon);
        Assert.assertEquals(Color.RED, largeIcon.getPixel(0, 0));

        // Verify that both notifications are silent when they are restored.
        Assert.assertEquals(
                Notification.GROUP_ALERT_SUMMARY,
                restoredSBNotifications.get(0).getNotification().getGroupAlertBehavior());
        Assert.assertEquals(
                Notification.GROUP_ALERT_SUMMARY,
                restoredSBNotifications.get(1).getNotification().getGroupAlertBehavior());

        // This should not have caused notifications permission to become denied.
        Assert.assertEquals("\"granted\"", runJavaScript("Notification.permission"));
        showNotification("Notification3", "{}");
        mNotificationTestRule.waitForNotificationCount(3);
    }

    /**
     * Verifies that activating the PendingIntent associated with the "Unsubscribe" button shows the
     * `provisionally unsubscribed` notification and suspends all existing notifications, even when
     * we are using service-type intents.
     *
     * <p>One-tap Unsubscribe is supported on Android P and later.
     */
    @Test
    @LargeTest
    @Feature({"Browser", "Notifications"})
    @CommandLineFlags.Add({
        "enable-features=" + ChromeFeatureList.NOTIFICATION_ONE_TAP_UNSUBSCRIBE + "<Study",
        "force-fieldtrials=Study/Group",
        "force-fieldtrial-params=Study.Group:use_service_intent/true"
    })
    @MinAndroidSdkLevel(Build.VERSION_CODES.P)
    public void testNotificationProvisionalUnsubscribeWithServiceIntent() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());
        Assert.assertEquals("\"granted\"", runJavaScript("Notification.permission"));

        Notification notification1 = showAndGetNotification("Notification1", "{}");
        showNotification("Notification2", "{}");
        mNotificationTestRule.waitForNotificationCount(2);

        // Click the "Unsubscribe" button.
        Assert.assertEquals(1, notification1.actions.length);
        PendingIntent unsubscribeIntent = notification1.actions[0].actionIntent;
        Assert.assertNotNull(unsubscribeIntent);
        unsubscribeIntent.send();

        // Wait for the two notifications to be collapsed and the `provisionally unsubscribed`
        // notification to appear.
        mNotificationTestRule.waitForNotificationCount(1);
    }

    /**
     * Verifies that creating a notification with an associated "tag" will cause any previous
     * notification with the same tag to be dismissed prior to being shown.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    public void testNotificationTagReplacement() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());
        // +0.5 engagement from navigating to the test page.
        Assert.assertEquals(0.5, getEngagementScoreBlocking(), 0);

        showNotification("First", "{tag: 'myTag'}");
        mNotificationTestRule.waitForNotificationManagerMutation();
        List<NotificationEntry> notifications = mNotificationTestRule.getNotificationEntries();
        String tag = notifications.get(0).tag;
        int id = notifications.get(0).id;

        showNotification("Second", "{tag: 'myTag'}");
        mNotificationTestRule.waitForNotificationManagerMutation();

        // Verify that the notification was successfully replaced.
        notifications = mNotificationTestRule.getNotificationEntries();
        Assert.assertEquals(1, notifications.size());
        Assert.assertEquals(
                "Second", NotificationTestUtil.getExtraTitle(notifications.get(0).notification));

        // Verify that for replaced notifications their tag was the same.
        Assert.assertEquals(tag, notifications.get(0).tag);

        // Verify that as always, the same integer is used, also for replaced notifications.
        Assert.assertEquals(id, notifications.get(0).id);
        Assert.assertEquals(NotificationPlatformBridge.PLATFORM_ID, notifications.get(0).id);

        // Engagement should not have changed since we didn't interact.
        Assert.assertEquals(0.5, getEngagementScoreBlocking(), 0);
    }

    /**
     * Verifies that multiple notifications without a tag can be opened and closed without affecting
     * eachother.
     */
    @Test
    @LargeTest
    @Feature({"Browser", "Notifications"})
    public void testShowAndCloseMultipleNotifications() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());
        // +0.5 engagement from navigating to the test page.
        Assert.assertEquals(0.5, getEngagementScoreBlocking(), 0);

        // Open the first notification and verify it is displayed.
        showNotification("One", "{}");
        mNotificationTestRule.waitForNotificationManagerMutation();
        List<NotificationEntry> notifications = mNotificationTestRule.getNotificationEntries();
        Assert.assertEquals(1, notifications.size());
        Notification notificationOne = notifications.get(0).notification;
        Assert.assertEquals("One", NotificationTestUtil.getExtraTitle(notificationOne));

        // Open the second notification and verify it is displayed.
        showNotification("Two", "{}");
        mNotificationTestRule.waitForNotificationManagerMutation();
        notifications = mNotificationTestRule.getNotificationEntries();
        Assert.assertEquals(2, notifications.size());
        Notification notificationTwo = notifications.get(1).notification;
        Assert.assertEquals("Two", NotificationTestUtil.getExtraTitle(notificationTwo));

        // The same integer id is always used as it is not needed for uniqueness, we rely on the tag
        // for uniqueness when the replacement behavior is not needed.
        Assert.assertEquals(NotificationPlatformBridge.PLATFORM_ID, notifications.get(0).id);
        Assert.assertEquals(NotificationPlatformBridge.PLATFORM_ID, notifications.get(1).id);

        // As these notifications were not meant to replace eachother, they must not have the same
        // tag internally.
        Assert.assertFalse(notifications.get(0).tag.equals(notifications.get(1).tag));

        // Verify that the PendingIntent for content and delete is different for each notification.
        Assert.assertFalse(notificationOne.contentIntent.equals(notificationTwo.contentIntent));
        Assert.assertFalse(notificationOne.deleteIntent.equals(notificationTwo.deleteIntent));

        // Close the first notification and verify that only the second remains.
        // Sending the content intent resembles touching the notification. In response tho this the
        // notificationclick event is fired. The test service worker will close the notification
        // upon receiving the event.
        notificationOne.contentIntent.send();
        mNotificationTestRule.waitForNotificationManagerMutation();
        notifications = mNotificationTestRule.getNotificationEntries();
        Assert.assertEquals(1, notifications.size());
        Assert.assertEquals(
                "Two", NotificationTestUtil.getExtraTitle(notifications.get(0).notification));

        // Expect +1 engagement from interacting with the notification.
        Assert.assertEquals(1.5, getEngagementScoreBlocking(), 0);

        // Close the last notification and verify that none remain.
        notifications.get(0).notification.contentIntent.send();
        mNotificationTestRule.waitForNotificationManagerMutation();
        Assert.assertTrue(mNotificationTestRule.getNotificationEntries().isEmpty());

        // Expect +1 engagement from interacting with the notification.
        Assert.assertEquals(2.5, getEngagementScoreBlocking(), 0);
    }

    /**
     * The next two tests verify that the PendingIntent associated with the "Unsubscribe" button is
     * either a broadcast or service type intent based on field trial configuration.
     *
     * <p>One-tap Unsubscribe is supported on Android P and later, but these tests rely on
     * `isBroadcast` and `isService` that was added in API level 31.
     */
    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    @CommandLineFlags.Add({
        "enable-features=" + ChromeFeatureList.NOTIFICATION_ONE_TAP_UNSUBSCRIBE + "<Study",
        "force-fieldtrials=Study/Group",
        "force-fieldtrial-params=Study.Group:use_service_intent/false"
    })
    @MinAndroidSdkLevel(Build.VERSION_CODES.S)
    @RequiresApi(Build.VERSION_CODES.S)
    public void testNotificationProvisionalUnsubscribeIsBroadcast() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());

        Notification notification = showAndGetNotification("Notification1", "{}");

        // Verify the "Unsubscribe" button's intent.
        Assert.assertEquals(1, notification.actions.length);
        PendingIntent unsubscribeIntent = notification.actions[0].actionIntent;
        Assert.assertNotNull(unsubscribeIntent);
        Assert.assertTrue(unsubscribeIntent.isBroadcast());
    }

    @Test
    @MediumTest
    @Feature({"Browser", "Notifications"})
    @CommandLineFlags.Add({
        "enable-features=" + ChromeFeatureList.NOTIFICATION_ONE_TAP_UNSUBSCRIBE + "<Study",
        "force-fieldtrials=Study/Group",
        "force-fieldtrial-params=Study.Group:use_service_intent/true"
    })
    @MinAndroidSdkLevel(Build.VERSION_CODES.S)
    @RequiresApi(Build.VERSION_CODES.S)
    public void testNotificationProvisionalUnsubscribeIsService() throws Exception {
        mNotificationTestRule.setNotificationContentSettingForOrigin(
                ContentSettingValues.ALLOW, mPermissionTestRule.getOrigin());

        Notification notification = showAndGetNotification("Notification1", "{}");

        // Verify the "Unsubscribe" button's intent.
        Assert.assertEquals(1, notification.actions.length);
        PendingIntent unsubscribeIntent = notification.actions[0].actionIntent;
        Assert.assertNotNull(unsubscribeIntent);
        Assert.assertTrue(unsubscribeIntent.isService());
    }

    /**
     * Shows a notification with |title| and |options|, waits until it has been displayed and then
     * returns the Notification object to the caller. Requires that only a single notification is
     * being displayed in the notification manager.
     *
     * @param title Title of the Web Notification to show.
     * @param options Optional map of options to include when showing the notification.
     * @return The Android Notification object, as shown in the framework.
     */
    private Notification showAndGetNotification(String title, String options)
            throws TimeoutException {
        showNotification(title, options);
        return mNotificationTestRule.waitForNotification().notification;
    }

    private void showNotification(String title, String options) throws TimeoutException {
        runJavaScript(
                "GetActivatedServiceWorkerForTest()"
                        + ".then(reg => reg.showNotification('"
                        + title
                        + "', "
                        + options
                        + "))"
                        + ".catch(sendToTest)");
    }

    private String runJavaScript(String code) throws TimeoutException {
        return mNotificationTestRule.runJavaScriptCodeInCurrentTab(code);
    }

    /** Get Notification related actions, filter all other actions to avoid flakes. */
    private List<String> getNotificationActions(UserActionTester actionTester) {
        List<String> actions = new ArrayList<>(actionTester.getActions());
        Iterator<String> it = actions.iterator();
        while (it.hasNext()) {
            if (!it.next().startsWith("Notifications.")) {
                it.remove();
            }
        }
        return actions;
    }
}