chromium/chrome/android/java/src/org/chromium/chrome/browser/notifications/NotificationPlatformBridge.java

// Copyright 2014 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 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.content.res.Resources;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.SystemClock;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.StyleSpan;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.NotificationManagerCompat;
import androidx.preference.PreferenceFragmentCompat;

import org.jni_zero.CalledByNative;
import org.jni_zero.JniType;
import org.jni_zero.NativeMethods;

import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.Promise;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeApplicationImpl;
import org.chromium.chrome.browser.browserservices.TrustedWebActivityClient;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.init.ChromeBrowserInitializer;
import org.chromium.chrome.browser.notifications.channels.SiteChannelsManager;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.chrome.browser.settings.SettingsLauncherFactory;
import org.chromium.chrome.browser.usage_stats.UsageStatsService;
import org.chromium.chrome.browser.webapps.ChromeWebApkHost;
import org.chromium.chrome.browser.webapps.WebApkServiceClient;
import org.chromium.components.browser_ui.notifications.BaseNotificationManagerProxy;
import org.chromium.components.browser_ui.notifications.BaseNotificationManagerProxyFactory;
import org.chromium.components.browser_ui.notifications.NotificationMetadata;
import org.chromium.components.browser_ui.notifications.NotificationWrapper;
import org.chromium.components.browser_ui.notifications.PendingIntentProvider;
import org.chromium.components.browser_ui.settings.SettingsLauncher;
import org.chromium.components.browser_ui.site_settings.SingleCategorySettings;
import org.chromium.components.browser_ui.site_settings.SingleWebsiteSettings;
import org.chromium.components.browser_ui.site_settings.SiteSettingsCategory;
import org.chromium.components.url_formatter.SchemeDisplay;
import org.chromium.components.url_formatter.UrlFormatter;
import org.chromium.components.user_prefs.UserPrefs;
import org.chromium.components.webapk.lib.client.WebApkValidator;
import org.chromium.content_public.browser.BrowserStartupController;
import org.chromium.url.URI;
import org.chromium.webapk.lib.client.WebApkIdentityServiceClient;

import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Predicate;
import java.util.stream.Collectors;

/**
 * Provides the ability for the NotificationPlatformBridgeAndroid to talk to the Android platform
 * notification system.
 *
 * <p>This class should only be used on the UI thread.
 */
public class NotificationPlatformBridge {
    private static final String TAG = NotificationPlatformBridge.class.getSimpleName();

    // We always use the same integer id when showing and closing notifications. The notification
    // tag is always set, which is a safe and sufficient way of identifying a notification, so the
    // integer id is not needed anymore except it must not vary in an uncontrolled way.
    public static final int PLATFORM_ID = -1;

    // We always use the same request code for pending intents. We use other ways to force
    // uniqueness of pending intents when necessary.
    private static final int PENDING_INTENT_REQUEST_CODE = 0;

    private static final int[] EMPTY_VIBRATION_PATTERN = new int[0];

    // The duration after which the "provisionally unsubscribed" service notification is auto-closed
    // and the permission revocation commits.
    // TODO(crbug.com/41494393): Fine tune this duration, and possibly turn it off for A11Y users.
    private static final long PROVISIONAL_UNSUBSCRIBE_DURATION_MS = 10 * 1000;

    private static NotificationPlatformBridge sInstance;

    private static BaseNotificationManagerProxy sNotificationManagerOverride;

    private final long mNativeNotificationPlatformBridge;

    private final BaseNotificationManagerProxy mNotificationManager;

    private long mLastNotificationClickMs;

    // The keys are origins that are currently showing the "provisionally unsubscribed" service
    // notification. For these origins we will revoke the permission after a grace period of
    // `PROVISIONAL_UNSUBSCRIBE_DURATION_MS`, unless the user hits the `ACTION_UNDO_UNSUBSCRIBE`.
    //
    // Each value in the map is a nested map that contains, as values, a "best-effort backup" of
    // notifications that the corresponding origin used to display, except the very notification
    // whose "Unsubscribe" action was clicked, as that notification is backed up reliably as
    // metadata on the "provisionally unsubscribed" notification. The keys in this nested map are
    // the `notificationId`s (i.e. tags).
    //
    // This map will be wiped empty if the application process is killed and then restarted.
    // However, this is unlikely during the brief `PROVISIONAL_UNSUBSCRIBE_DURATION_MS` period.
    // Even if it happens, it is not catastrophic, namely:
    //  a) the revocation will still happen as that is wired up to the provisionally unsubscribed
    //     notification getting closed,
    //  b) however, we won't suppress new notifications from this origin anymore,
    //  c) in the case the user choses to "Undo", we will only be able to restore the notification
    //     they originally clicked "Unsubscribe" on.
    private static Map<String, Map<String, Notification>>
            sOriginsWithProvisionallyRevokedPermissions =
                    new HashMap<String, Map<String, Notification>>();

    // The `realtimeMillis` timestamp corresponding to the last time the pre-native processing for
    // the `PRE_UNSUBSCRIBE` intent was started. Used to measure the time, as perceived by the user,
    // that elapses until we see a duplicate intent being dispatched.
    private static long sLastPreUnsubscribePreNativeTaskStartRealMillis = -1;

    private TrustedWebActivityClient mTwaClient;

    /** Encapsulates attributes that identify a notification and where it originates from. */
    private static class NotificationIdentifyingAttributes {
        public final String notificationId;
        public final @NotificationType int notificationType;
        public final String origin;
        public final String scopeUrl;
        public final String profileId;
        public final boolean incognito;
        public final String webApkPackage;

        public NotificationIdentifyingAttributes(
                String notificationId,
                @NotificationType int notificationType,
                String origin,
                String scopeUrl,
                String profileId,
                boolean incognito,
                String webApkPackage) {
            this.notificationId = notificationId;
            this.notificationType = notificationType;
            this.origin = origin;
            this.scopeUrl = scopeUrl;
            this.profileId = profileId;
            this.incognito = incognito;
            this.webApkPackage = webApkPackage;
        }

        /** Extracts a notification's identifying attributes from `intent` extras. */
        public static NotificationIdentifyingAttributes extractFromIntent(Intent intent) {
            return new NotificationIdentifyingAttributes(
                    /* notificationId= */ intent.getStringExtra(
                            NotificationConstants.EXTRA_NOTIFICATION_ID),
                    /* notificationType= */ intent.getIntExtra(
                            NotificationConstants.EXTRA_NOTIFICATION_TYPE,
                            NotificationType.WEB_PERSISTENT),
                    /* origin= */ intent.getStringExtra(
                            NotificationConstants.EXTRA_NOTIFICATION_INFO_ORIGIN),
                    /* scopeUrl= */ Objects.requireNonNullElse(
                            intent.getStringExtra(
                                    NotificationConstants.EXTRA_NOTIFICATION_INFO_SCOPE),
                            ""),
                    /* profileId= */ intent.getStringExtra(
                            NotificationConstants.EXTRA_NOTIFICATION_INFO_PROFILE_ID),
                    /* incognito= */ intent.getBooleanExtra(
                            NotificationConstants.EXTRA_NOTIFICATION_INFO_PROFILE_INCOGNITO, false),
                    /* webApkPackage= */ Objects.requireNonNullElse(
                            intent.getStringExtra(
                                    NotificationConstants.EXTRA_NOTIFICATION_INFO_WEBAPK_PACKAGE),
                            ""));
        }
    }

    /**
     * Creates a new instance of the NotificationPlatformBridge.
     *
     * @param nativeNotificationPlatformBridge Instance of the NotificationPlatformBridgeAndroid
     *     class.
     */
    @CalledByNative
    private static NotificationPlatformBridge create(long nativeNotificationPlatformBridge) {
        if (sInstance != null) {
            throw new IllegalStateException(
                    "There must only be a single NotificationPlatformBridge.");
        }

        sInstance = new NotificationPlatformBridge(nativeNotificationPlatformBridge);
        return sInstance;
    }

    /**
     * Returns the current instance of the NotificationPlatformBridge.
     *
     * @return The instance of the NotificationPlatformBridge, if any.
     */
    @Nullable
    static NotificationPlatformBridge getInstanceForTests() {
        return sInstance;
    }

    /**
     * Overrides the notification manager which is to be used for displaying Notifications on the
     * Android framework. Should only be used for testing. Tests are expected to clean up after
     * themselves by setting this to NULL again.
     *
     * @param notificationManager The notification manager instance to use instead of the system's.
     */
    static void overrideNotificationManagerForTesting(
            BaseNotificationManagerProxy notificationManager) {
        sNotificationManagerOverride = notificationManager;
    }

    /**
     * Retuns the abstraction around the NotificationManager that either delegates to the real thing
     * in production code or to a fake in tests.
     */
    private static BaseNotificationManagerProxy createNotificationManagerProxy(Context context) {
        BaseNotificationManagerProxy notificationManager;
        if (sNotificationManagerOverride != null) {
            notificationManager = sNotificationManagerOverride;
        } else {
            notificationManager = BaseNotificationManagerProxyFactory.create(context);
        }
        return notificationManager;
    }

    private NotificationPlatformBridge(long nativeNotificationPlatformBridge) {
        mNativeNotificationPlatformBridge = nativeNotificationPlatformBridge;
        Context context = ContextUtils.getApplicationContext();
        mNotificationManager = createNotificationManagerProxy(context);
    }

    /**
     * Marks the current instance as being freed, allowing for a new NotificationPlatformBridge
     * object to be initialized.
     */
    @CalledByNative
    private void destroy() {
        assert sInstance == this;
        sInstance = null;
    }

    /**
     * Invoked by the NotificationService immediately after a Notification intent has been received
     * and before scheduling a background job to perform the heavy lifting to handle it.
     *
     * <p>This method must work without native libraries loaded and/or assuming that all Java-side
     * global state exists.
     *
     * @param intent The intent as received by the Notification service.
     * @return `true` if the `intent` requires further native processing, `false` otherwise.
     */
    static boolean dispatchNotificationEventPreNative(Intent intent) {
        NotificationIdentifyingAttributes attributes =
                NotificationIdentifyingAttributes.extractFromIntent(intent);
        if (NotificationConstants.ACTION_PRE_UNSUBSCRIBE.equals(intent.getAction())) {
            onNotificationPreUnsubcribe(attributes);
            return false;
        } else if (NotificationConstants.ACTION_UNDO_UNSUBSCRIBE.equals(intent.getAction())) {
            onNotificationUndoUnsubscribe(attributes);
            return false;
        } else if (NotificationConstants.ACTION_COMMIT_UNSUBSCRIBE.equals(intent.getAction())) {
            // Cancel notification immediately so that the user perceives the action to have been
            // recognized; but return `true` as we still need native processing later to actually
            // revoke the permission. Also keep the `sOriginsWithProvisionallyRevokedPermissions` in
            // place until native processing finishes in case there are other user interactions
            // racing with this intent.
            Context context = ContextUtils.getApplicationContext();
            BaseNotificationManagerProxy notificationManager =
                    createNotificationManagerProxy(context);
            notificationManager.cancel(attributes.notificationId, PLATFORM_ID);
            return true;
        }

        // All other intents handled from native.
        return true;
    }

    /**
     * Invoked by the NotificationService when a Notification intent has been received. There may
     * not be an active instance of the NotificationPlatformBridge at this time, so inform the
     * native side through a static method, initializing both ends if needed.
     *
     * @param intent The intent as received by the Notification service.
     * @return Whether the event could be handled by the native Notification bridge.
     */
    static boolean dispatchNotificationEvent(Intent intent) {
        if (sInstance == null) {
            NotificationPlatformBridgeJni.get().initializeNotificationPlatformBridge();
            if (sInstance == null) {
                Log.e(TAG, "Unable to initialize the native NotificationPlatformBridge.");
                return false;
            }
        }
        recordJobStartDelayUMA(intent);
        recordJobNativeStartupDuration(intent);

        NotificationIdentifyingAttributes attributes =
                NotificationIdentifyingAttributes.extractFromIntent(intent);
        Log.i(
                TAG,
                String.format(
                        "Dispatching notification event to native: id=%s action=%s",
                        attributes.notificationId, intent.getAction()));

        if (NotificationConstants.ACTION_CLICK_NOTIFICATION.equals(intent.getAction())) {
            int actionIndex =
                    intent.getIntExtra(
                            NotificationConstants.EXTRA_NOTIFICATION_INFO_ACTION_INDEX, -1);
            sInstance.onNotificationClicked(attributes, actionIndex, getNotificationReply(intent));
            return true;
        } else if (NotificationConstants.ACTION_CLOSE_NOTIFICATION.equals(intent.getAction())) {
            // Notification deleteIntent is executed only "when the notification is explicitly
            // dismissed by the user, either with the 'Clear All' button or by swiping it away
            // individually" (though a third-party NotificationListenerService may also trigger it).
            sInstance.onNotificationClosed(attributes, /* byUser= */ true);
            return true;
        } else if (NotificationConstants.ACTION_COMMIT_UNSUBSCRIBE.equals(intent.getAction())) {
            sInstance.onNotificationCommitUnsubscribe(attributes);
            return true;
        }

        Log.e(TAG, "Unrecognized Notification action: " + intent.getAction());
        return false;
    }

    private static void recordJobStartDelayUMA(Intent intent) {
        if (intent.hasExtra(NotificationConstants.EXTRA_JOB_SCHEDULED_TIME_MS)
                && intent.hasExtra(NotificationConstants.EXTRA_JOB_STARTED_TIME_MS)) {
            long duration =
                    intent.getLongExtra(NotificationConstants.EXTRA_JOB_STARTED_TIME_MS, -1)
                            - intent.getLongExtra(
                                    NotificationConstants.EXTRA_JOB_SCHEDULED_TIME_MS, -1);
            if (duration < 0) return; // Possible if device rebooted before job started.
            RecordHistogram.recordMediumTimesHistogram(
                    "Notifications.Android.JobStartDelay", duration);
            if (NotificationConstants.ACTION_PRE_UNSUBSCRIBE.equals(intent.getAction())) {
                RecordHistogram.recordMediumTimesHistogram(
                        "Notifications.Android.JobStartDelay.PreUnsubscribe", duration);
            }
        }
    }

    private static void recordJobNativeStartupDuration(Intent intent) {
        if (intent.hasExtra(NotificationConstants.EXTRA_JOB_STARTED_TIME_MS)) {
            long duration =
                    SystemClock.elapsedRealtime()
                            - intent.getLongExtra(
                                    NotificationConstants.EXTRA_JOB_STARTED_TIME_MS, -1);
            RecordHistogram.recordMediumTimesHistogram(
                    "Notifications.Android.JobNativeStartupDuration", duration);
            if (NotificationConstants.ACTION_PRE_UNSUBSCRIBE.equals(intent.getAction())) {
                RecordHistogram.recordMediumTimesHistogram(
                        "Notifications.Android.JobNativeStartupDuration.PreUnsubscribe", duration);
            }
        }
    }

    static @Nullable String getNotificationReply(Intent intent) {
        if (intent.getStringExtra(NotificationConstants.EXTRA_NOTIFICATION_REPLY) != null) {
            // If the notification click went through the job scheduler, we will have set
            // the reply as a standard string extra.
            return intent.getStringExtra(NotificationConstants.EXTRA_NOTIFICATION_REPLY);
        }
        Bundle remoteInputResults = RemoteInput.getResultsFromIntent(intent);
        if (remoteInputResults != null) {
            CharSequence reply =
                    remoteInputResults.getCharSequence(NotificationConstants.KEY_TEXT_REPLY);
            if (reply != null) {
                return reply.toString();
            }
        }
        return null;
    }

    /**
     * Launches the notifications preferences screen. If the received intent indicates it came
     * from the gear button on a flipped notification, this launches the site specific preferences
     * screen.
     *
     * @param incomingIntent The received intent.
     */
    public static void launchNotificationPreferences(Intent incomingIntent) {
        // This method handles an intent fired by the Android system. There is no guarantee that the
        // native library is loaded at this point. The native library is needed for the preferences
        // activity, and it loads the library, but there are some native calls even before that
        // activity is started: from RecordUserAction.record and (indirectly) from
        // UrlFormatter.formatUrlForSecurityDisplay.
        ChromeBrowserInitializer.getInstance().handleSynchronousStartup();

        // Use the application context because it lives longer. When using the given context, it
        // may be stopped before the preferences intent is handled.
        Context applicationContext = ContextUtils.getApplicationContext();

        // If we can read an origin from the intent, use it to open the settings screen for that
        // origin.
        String origin = getOriginFromIntent(incomingIntent);
        boolean launchSingleWebsitePreferences = origin != null;

        Bundle fragmentArguments;
        if (launchSingleWebsitePreferences) {
            // Record that the user has clicked on the [Site Settings] button.
            RecordUserAction.record("Notifications.ShowSiteSettings");

            // All preferences for a specific origin.
            fragmentArguments = SingleWebsiteSettings.createFragmentArgsForSite(origin);
        } else {
            // Notification preferences for all origins.
            fragmentArguments = new Bundle();
            fragmentArguments.putString(
                    SingleCategorySettings.EXTRA_CATEGORY,
                    SiteSettingsCategory.preferenceKey(SiteSettingsCategory.Type.NOTIFICATIONS));
            fragmentArguments.putString(
                    SingleCategorySettings.EXTRA_TITLE,
                    applicationContext
                            .getResources()
                            .getString(R.string.push_notifications_permission_title));
        }

        Class<? extends PreferenceFragmentCompat> fragment =
                launchSingleWebsitePreferences
                        ? SingleWebsiteSettings.class
                        : SingleCategorySettings.class;
        SettingsLauncher settingsLauncher = SettingsLauncherFactory.createSettingsLauncher();
        settingsLauncher.launchSettingsActivity(applicationContext, fragment, fragmentArguments);
    }

    /**
     * Returns a bogus Uri used to make each intent unique according to Intent#filterEquals. Without
     * this, the pending intents derived from the intent may be reused, because extras are not taken
     * into account for the filterEquals comparison.
     *
     * @param notificationId The id of the notification.
     * @param origin The origin to whom the notification belongs.
     * @param actionIndex The zero-based index of the action button, or -1 if not applicable.
     */
    private static Uri makeIntentData(String notificationId, String origin, int actionIndex) {
        return Uri.parse(origin).buildUpon().fragment(notificationId + "," + actionIndex).build();
    }

    /**
     * Returns the PendingIntent for completing |action| on the notification identified by the data
     * in the other parameters.
     *
     * <p>All parameters set here should also be set in {@link
     * NotificationJobService#getJobExtrasFromIntent(Intent)}.
     *
     * @param attributes Attributes identifying the notification and its source.
     * @param action The action this pending intent will represent.
     * @param actionIndex The zero-based index of the action button, or -1 if not applicable.
     * @param mutable Whether the pending intent is mutable, see {@link
     *     PendingIntent#FLAG_IMMUTABLE}.
     */
    private static PendingIntentProvider makePendingIntent(
            NotificationIdentifyingAttributes attributes,
            String action,
            int actionIndex,
            boolean mutable) {
        Context context = ContextUtils.getApplicationContext();
        Uri intentData = makeIntentData(attributes.notificationId, attributes.origin, actionIndex);
        // TODO(crbug.com/359909538): Telemetry shows that startService-type intents are even more
        // unreliable than broadcasts. Furthermore, checking the feature state is currently the only
        // place in this method that in theory requires native startup. In practice, we will only
        // ever get called with ACTION_PRE_UNSUBSCRIBE when displaying a web notification, which
        // implies native is running, making this a non-issue. Neverthelerss, removing support for
        // startService-type intents would be the cleanest solution here.
        boolean useServiceIntent =
                NotificationConstants.ACTION_PRE_UNSUBSCRIBE.equals(action)
                        && NotificationIntentInterceptor
                                .shouldUseServiceIntentForPreUnsubscribeAction();
        Intent intent = new Intent(action, intentData);
        intent.setClass(
                context,
                useServiceIntent
                        ? NotificationService.class
                        : NotificationServiceImpl.Receiver.class);

        // Make sure to update NotificationJobService.getJobExtrasFromIntent() when changing any
        // of the extras included with the |intent|.
        intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_ID, attributes.notificationId);
        intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_TYPE, attributes.notificationType);
        intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_ORIGIN, attributes.origin);
        intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_SCOPE, attributes.scopeUrl);
        intent.putExtra(
                NotificationConstants.EXTRA_NOTIFICATION_INFO_PROFILE_ID, attributes.profileId);
        intent.putExtra(
                NotificationConstants.EXTRA_NOTIFICATION_INFO_PROFILE_INCOGNITO,
                attributes.incognito);
        intent.putExtra(
                NotificationConstants.EXTRA_NOTIFICATION_INFO_WEBAPK_PACKAGE,
                attributes.webApkPackage);
        intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_ACTION_INDEX, actionIndex);

        // This flag ensures the broadcast is delivered with foreground priority. It also means the
        // receiver gets a shorter timeout interval before it may be killed, but this is ok because
        // we schedule a job to handle the intent in NotificationService.Receiver.
        intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);

        if (useServiceIntent) {
            return PendingIntentProvider.getService(
                    context,
                    PENDING_INTENT_REQUEST_CODE,
                    intent,
                    PendingIntent.FLAG_UPDATE_CURRENT,
                    mutable);
        }

        return PendingIntentProvider.getBroadcast(
                context,
                PENDING_INTENT_REQUEST_CODE,
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT,
                mutable);
    }

    /**
     * Attempts to extract an origin from the tag extras in the given intent.
     *
     * There are two tags that are relevant, either or none of them may be set, but not both:
     *   1. Notification.EXTRA_CHANNEL_ID - set by Android on the 'Additional settings in the app'
     *      button intent from individual channel settings screens in Android O.
     *   2. NotificationConstants.EXTRA_NOTIFICATION_TAG - set by us on browser UI that should
     *     launch specific site settings, e.g. the web notifications Site Settings button.
     *
     * See {@link SiteChannelsManager#createChannelId} and {@link SiteChannelsManager#toSiteOrigin}
     * for how we convert origins to and from channel ids.
     *
     * @param intent The incoming intent.
     * @return The origin string. Returns null if there was no relevant tag extra in the given
     * intent, or if a relevant notification tag value did not match the expected format.
     */
    private static @Nullable String getOriginFromIntent(Intent intent) {
        String originFromChannelId =
                getOriginFromChannelId(intent.getStringExtra(Notification.EXTRA_CHANNEL_ID));
        return originFromChannelId != null
                ? originFromChannelId
                : getOriginFromNotificationTag(
                        intent.getStringExtra(NotificationConstants.EXTRA_NOTIFICATION_TAG));
    }

    /**
     * Gets origin from the notification tag.
     * If the user touched the settings cog on a flipped notification originating from this
     * class, there will be a notification tag extra in a specific format. From the tag we can
     * read the origin of the notification.
     *
     * @param tag The notification tag to extract origin from.
     * @return The origin string. Return null if there was no tag extra in the given notification
     * tag, or if the notification tag didn't match the expected format.
     */
    public static @Nullable String getOriginFromNotificationTag(@Nullable String tag) {
        if (tag == null
                || !tag.startsWith(
                        NotificationConstants.PERSISTENT_NOTIFICATION_TAG_PREFIX
                                + NotificationConstants.NOTIFICATION_TAG_SEPARATOR)) {
            return null;
        }

        // This code parses the notification id that was generated in notification_id_generator.cc
        // TODO(crbug.com/41364310): Extract this to a separate class.
        String[] parts = tag.split(NotificationConstants.NOTIFICATION_TAG_SEPARATOR);
        assert parts.length >= 3;
        try {
            URI uri = new URI(parts[1]);
            if (uri.getHost() != null) return parts[1];
        } catch (URISyntaxException e) {
            Log.e(TAG, "Expected to find a valid url in the notification tag extra.", e);
            return null;
        }
        return null;
    }

    @Nullable
    @VisibleForTesting
    static String getOriginFromChannelId(@Nullable String channelId) {
        if (channelId == null || !SiteChannelsManager.isValidSiteChannelId(channelId)) {
            return null;
        }
        return SiteChannelsManager.toSiteOrigin(channelId);
    }

    /**
     * Generates the notification defaults from vibrationPattern's size and silent.
     *
     * Use the system's default ringtone, vibration and indicator lights unless the notification
     * has been marked as being silent.
     * If a vibration pattern is set, the notification should use the provided pattern
     * rather than defaulting to the system settings.
     *
     * @param vibrationPatternLength Vibration pattern's size for the Notification.
     * @param silent Whether the default sound, vibration and lights should be suppressed.
     * @param vibrateEnabled Whether vibration is enabled in preferences.
     * @return The generated notification's default value.
     */
    @VisibleForTesting
    static int makeDefaults(int vibrationPatternLength, boolean silent, boolean vibrateEnabled) {
        assert !silent || vibrationPatternLength == 0;

        if (silent) return 0;

        int defaults = Notification.DEFAULT_ALL;
        if (vibrationPatternLength > 0 || !vibrateEnabled) {
            defaults &= ~Notification.DEFAULT_VIBRATE;
        }
        return defaults;
    }

    /**
     * Generates the vibration pattern used in Android notification.
     *
     * Android takes a long array where the first entry indicates the number of milliseconds to wait
     * prior to starting the vibration, whereas Chrome follows the syntax of the Web Vibration API.
     *
     * @param vibrationPattern Vibration pattern following the Web Vibration API syntax.
     * @return Vibration pattern following the Android syntax.
     */
    @VisibleForTesting
    static long[] makeVibrationPattern(int[] vibrationPattern) {
        long[] pattern = new long[vibrationPattern.length + 1];
        for (int i = 0; i < vibrationPattern.length; ++i) {
            pattern[i + 1] = vibrationPattern[i];
        }
        return pattern;
    }

    /**
     * Displays a notification with the given details.
     *
     * @param notificationId The id of the notification.
     * @param origin Full text of the origin, including the protocol, owning this notification.
     * @param scopeUrl The scope of the service worker registered by the site where the notification
     *     comes from.
     * @param profileId Id of the profile that showed the notification.
     * @param profile The profile that showed the notification.
     * @param title Title to be displayed in the notification.
     * @param body Message to be displayed in the notification. Will be trimmed to one line of text
     *     by the Android notification system.
     * @param image Content image to be prominently displayed when the notification is expanded.
     * @param icon Icon to be displayed in the notification. Valid Bitmap icons will be scaled to
     *     the platforms, whereas a default icon will be generated for invalid Bitmaps.
     * @param badge An image to represent the notification in the status bar. It is also displayed
     *     inside the notification.
     * @param vibrationPattern Vibration pattern following the Web Vibration syntax.
     * @param timestamp The timestamp of the event for which the notification is being shown.
     * @param renotify Whether the sound, vibration, and lights should be replayed if the
     *     notification is replacing another notification.
     * @param silent Whether the default sound, vibration and lights should be suppressed.
     * @param actions Action buttons to display alongside the notification.
     * @see <a href="https://developer.android.com/reference/android/app/Notification.html">Android
     *     Notification API</a>
     */
    @CalledByNative
    private void displayNotification(
            @JniType("std::string") final String notificationId,
            @NotificationType final int notificationType,
            @JniType("std::string") final String origin,
            @JniType("std::string") final String scopeUrl,
            @JniType("std::string") final String profileId,
            final Profile profile,
            @JniType("std::u16string") final String title,
            @JniType("std::u16string") final String body,
            @JniType("SkBitmap") final Bitmap image,
            @JniType("SkBitmap") final Bitmap icon,
            @JniType("SkBitmap") final Bitmap badge,
            @JniType("std::vector<int32_t>") final int[] vibrationPattern,
            final long timestamp,
            final boolean renotify,
            final boolean silent,
            final ActionInfo[] actions) {
        final boolean vibrateEnabled =
                UserPrefs.get(ProfileManager.getLastUsedRegularProfile())
                        .getBoolean(NOTIFICATIONS_VIBRATE_ENABLED);
        final boolean incognito = profile.isOffTheRecord();
        // TODO(peter): by-pass this check for non-Web Notification types.
        getWebApkPackage(scopeUrl)
                .then(
                        (Callback<String>)
                                (webApkPackage) ->
                                        displayNotificationInternal(
                                                new NotificationIdentifyingAttributes(
                                                        notificationId,
                                                        notificationType,
                                                        origin,
                                                        scopeUrl,
                                                        profileId,
                                                        incognito,
                                                        webApkPackage),
                                                profile,
                                                vibrateEnabled,
                                                title,
                                                body,
                                                image,
                                                icon,
                                                badge,
                                                vibrationPattern,
                                                timestamp,
                                                renotify,
                                                silent,
                                                actions));
    }

    private Promise<String> getWebApkPackage(String scopeUrl) {
        String webApkPackage =
                WebApkValidator.queryFirstWebApkPackage(
                        ContextUtils.getApplicationContext(), scopeUrl);
        if (webApkPackage == null) return Promise.fulfilled("");
        Promise<String> promise = new Promise<>();
        ChromeWebApkHost.checkChromeBacksWebApkAsync(
                webApkPackage,
                (doesBrowserBackWebApk, browserPackageName) ->
                        promise.fulfill(doesBrowserBackWebApk ? webApkPackage : ""));
        return promise;
    }

    /** Called after querying whether the browser backs the given WebAPK. */
    private void displayNotificationInternal(
            NotificationIdentifyingAttributes identifyingAttributes,
            Profile profile,
            boolean vibrateEnabled,
            String title,
            String body,
            Bitmap image,
            Bitmap icon,
            Bitmap badge,
            int[] vibrationPattern,
            long timestamp,
            boolean renotify,
            boolean silent,
            ActionInfo[] actions) {
        NotificationPlatformBridgeJni.get()
                .storeCachedWebApkPackageForNotificationId(
                        mNativeNotificationPlatformBridge,
                        NotificationPlatformBridge.this,
                        identifyingAttributes.notificationId,
                        identifyingAttributes.webApkPackage);
        // Record whether it's known whether notifications can be shown to the user at all.
        NotificationSystemStatusUtil.recordAppNotificationStatusHistogram();

        NotificationBuilderBase notificationBuilder =
                prepareNotificationBuilder(
                        identifyingAttributes,
                        vibrateEnabled,
                        title,
                        body,
                        image,
                        icon,
                        badge,
                        vibrationPattern,
                        timestamp,
                        renotify,
                        silent,
                        actions);

        notificationBuilder.setContentIntent(
                makePendingIntent(
                        identifyingAttributes,
                        NotificationConstants.ACTION_CLICK_NOTIFICATION,
                        /* actionIndex= */ -1,
                        /* mutable= */ false));

        notificationBuilder.setDeleteIntent(
                makePendingIntent(
                        identifyingAttributes,
                        NotificationConstants.ACTION_CLOSE_NOTIFICATION,
                        /* actionIndex= */ -1,
                        /* mutable= */ false));

        // Delegate notification to WebAPK.
        if (!identifyingAttributes.webApkPackage.isEmpty()) {
            WebApkServiceClient.getInstance()
                    .notifyNotification(
                            identifyingAttributes.origin,
                            identifyingAttributes.webApkPackage,
                            notificationBuilder,
                            identifyingAttributes.notificationId,
                            PLATFORM_ID);
            return;
        }

        // Delegate notification to TWA.
        Uri scopeUri = Uri.parse(identifyingAttributes.scopeUrl);
        if (getTwaClient().twaExistsForScope(scopeUri)) {
            getTwaClient()
                    .notifyNotification(
                            scopeUri,
                            identifyingAttributes.notificationId,
                            PLATFORM_ID,
                            notificationBuilder,
                            NotificationUmaTracker.getInstance());
            return;
        }

        if (ChromeFeatureList.isEnabled(ChromeFeatureList.NOTIFICATION_ONE_TAP_UNSUBSCRIBE)
                && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
                && identifyingAttributes.notificationType == NotificationType.WEB_PERSISTENT) {
            appendUnsubscribeButton(notificationBuilder, identifyingAttributes);
        } else {
            appendSiteSettingsButton(
                    notificationBuilder,
                    identifyingAttributes.notificationId,
                    identifyingAttributes.origin,
                    actions);
        }

        NotificationWrapper notification =
                buildNotificationWrapper(notificationBuilder, identifyingAttributes.notificationId);

        // Either display the notification right away; or, if this kind of notification is currently
        // under suspension, store the notification's resources back into the NotificationDatabase.
        // Once the suspension is over, displayNotification() will be called again.
        storeNotificationResourcesIfSuspended(identifyingAttributes, profile, notification)
                .then(
                        (suspended) -> {
                            if (suspended) {
                                return;
                            }

                            // Display notification as Chrome.
                            // Android may throw an exception on
                            // INotificationManager.enqueueNotificationWithTag,
                            // see crbug.com/1077027.
                            try {
                                mNotificationManager.notify(notification);
                                NotificationUmaTracker.getInstance()
                                        .onNotificationShown(
                                                NotificationUmaTracker.SystemNotificationType.SITES,
                                                notification.getNotification());
                            } catch (RuntimeException e) {
                                Log.e(
                                        TAG,
                                        "Failed to send notification, the IPC message might be"
                                                + " corrupted.");
                            }
                        });

        // If Chrome has no app-level notifications permission, check if an origin-level permission
        // should be revoked.
        // Notifications permission is not allowed for incognito profile.
        if (!identifyingAttributes.origin.isEmpty() && !identifyingAttributes.incognito) {
            NotificationManagerCompat manager =
                    NotificationManagerCompat.from(ContextUtils.getApplicationContext());
            PushMessagingServiceBridge.getInstance()
                    .verify(
                            identifyingAttributes.origin,
                            identifyingAttributes.profileId,
                            manager.areNotificationsEnabled());
        }
    }

    private Promise<Boolean> storeNotificationResourcesIfSuspended(
            NotificationIdentifyingAttributes identifyingAttributes,
            Profile profile,
            NotificationWrapper notification) {
        if (identifyingAttributes.notificationType != NotificationType.WEB_PERSISTENT) {
            return Promise.fulfilled(false);
        }

        if (sOriginsWithProvisionallyRevokedPermissions.containsKey(identifyingAttributes.origin)) {
            return Promise.fulfilled(true);
        }

        if (!UsageStatsService.isEnabled()) {
            return Promise.fulfilled(false);
        }

        // Only native calls into this here code, so the native process must be running, which is
        // important if we end up lazily constructing `UsageStatsService` here, which uses JNI.
        assert BrowserStartupController.getInstance().isFullBrowserStarted();
        return UsageStatsService.getForProfile(profile)
                .getSuspensionTracker()
                .storeNotificationResourcesIfSuspended(notification);
    }

    private static NotificationBuilderBase prepareNotificationBuilder(
            NotificationIdentifyingAttributes identifyingAttributes,
            boolean vibrateEnabled,
            String title,
            String body,
            Bitmap image,
            Bitmap icon,
            Bitmap badge,
            int[] vibrationPattern,
            long timestamp,
            boolean renotify,
            boolean silent,
            ActionInfo[] actions) {
        Context context = ContextUtils.getApplicationContext();

        final boolean hasImage = image != null;
        final boolean forWebApk = !identifyingAttributes.webApkPackage.isEmpty();
        final String origin = identifyingAttributes.origin;
        NotificationBuilderBase notificationBuilder =
                new StandardNotificationBuilder(context)
                        .setTitle(title)
                        .setBody(body)
                        .setImage(image)
                        .setLargeIcon(icon)
                        .setSmallIconId(R.drawable.ic_chrome)
                        .setStatusBarIcon(badge)
                        .setSmallIconForContent(badge)
                        .setTicker(createTickerText(title, body))
                        .setTimestamp(timestamp)
                        .setRenotify(renotify)
                        .setOrigin(
                                UrlFormatter.formatUrlForSecurityDisplay(
                                        origin, SchemeDisplay.OMIT_HTTP_AND_HTTPS));

        if (shouldSetChannelId(forWebApk)) {
            // TODO(crbug.com/40544272): Channel ID should be retrieved from cache in native and
            // passed through to here with other notification parameters.
            String channelId = SiteChannelsManager.getInstance().getChannelIdForOrigin(origin);
            notificationBuilder.setChannelId(channelId);
        }

        for (int actionIndex = 0; actionIndex < actions.length; actionIndex++) {
            ActionInfo action = actions[actionIndex];
            boolean mutable = (action.type == NotificationActionType.TEXT);
            PendingIntentProvider intent =
                    makePendingIntent(
                            identifyingAttributes,
                            NotificationConstants.ACTION_CLICK_NOTIFICATION,
                            actionIndex,
                            mutable);
            // Don't show action button icons when there's an image, as then action buttons go on
            // the same row as the Site Settings button, so icons wouldn't leave room for text.
            Bitmap actionIcon = hasImage ? null : action.icon;
            if (action.type == NotificationActionType.TEXT) {
                notificationBuilder.addTextAction(
                        actionIcon, action.title, intent, action.placeholder);
            } else {
                notificationBuilder.addButtonAction(actionIcon, action.title, intent);
            }
        }

        // The Android framework applies a fallback vibration pattern for the sound when the device
        // is in vibrate mode, there is no custom pattern, and the vibration default has been
        // disabled. To truly prevent vibration, provide a custom empty pattern.
        if (!vibrateEnabled) {
            vibrationPattern = EMPTY_VIBRATION_PATTERN;
        }
        notificationBuilder.setDefaults(
                makeDefaults(vibrationPattern.length, silent, vibrateEnabled));
        notificationBuilder.setVibrate(makeVibrationPattern(vibrationPattern));
        notificationBuilder.setSilent(silent);

        return notificationBuilder;
    }

    /**
     * Displays a service notification informing the user that they have unsubscribed from
     * notifications from a given site.
     *
     * <p>To implement undo in simple terms, the permission will not yet actually be revoked while
     * this notification is showing. Instead, the permission is revoked when this notification is
     * OK'ed, dismissed, or times out.
     */
    private static void displayProvisionallyUnsubscribedNotification(
            NotificationIdentifyingAttributes identifyingAttributes, Bundle extras) {
        Context context = ContextUtils.getApplicationContext();
        Resources res = context.getResources();

        NotificationBuilderBase notificationBuilder =
                prepareNotificationBuilder(
                        identifyingAttributes,
                        /* vibrateEnabled= */ false,
                        res.getString(R.string.notification_provisionally_unsubscribed_title),
                        res.getString(
                                R.string.notification_provisionally_unsubscribed_body,
                                UrlFormatter.formatUrlForSecurityDisplay(
                                        identifyingAttributes.origin,
                                        SchemeDisplay.OMIT_HTTP_AND_HTTPS)),
                        /* image= */ null,
                        /* icon= */ null,
                        /* badge= */ null,
                        /* vibrationPattern= */ null,
                        /* timestamp= */ -1,
                        /* renotify= */ false,
                        /* silent= */ true,
                        /* actions= */ new ActionInfo[] {});

        if (shouldSetChannelId(/* forWebApk= */ false)) {
            String channelId =
                    SiteChannelsManager.getInstance()
                            .getChannelIdForOrigin(identifyingAttributes.origin);
            notificationBuilder.setChannelId(channelId);
        }

        // TODO(crbug.com/41494407): We are setting quite a few uncommon attributes here, consider
        // just not using NotificationBuilderBase.
        notificationBuilder.setSuppressShowingLargeIcon(true);
        notificationBuilder.setTimeoutAfter(PROVISIONAL_UNSUBSCRIBE_DURATION_MS);
        notificationBuilder.setExtras(extras);

        notificationBuilder.setDeleteIntent(
                makePendingIntent(
                        identifyingAttributes,
                        NotificationConstants.ACTION_COMMIT_UNSUBSCRIBE,
                        /* actionIndex= */ -1,
                        /* mutable= */ false),
                NotificationUmaTracker.ActionType.COMMIT_UNSUBSCRIBE_IMPLICIT);

        addProvisionallyUnsubscribedNotificationAction(
                notificationBuilder,
                identifyingAttributes,
                NotificationConstants.ACTION_UNDO_UNSUBSCRIBE,
                NotificationUmaTracker.ActionType.UNDO_UNSUBSCRIBE,
                res.getString(R.string.notification_undo_unsubscribe_button));

        addProvisionallyUnsubscribedNotificationAction(
                notificationBuilder,
                identifyingAttributes,
                NotificationConstants.ACTION_COMMIT_UNSUBSCRIBE,
                NotificationUmaTracker.ActionType.COMMIT_UNSUBSCRIBE_EXPLICIT,
                res.getString(R.string.notification_commit_unsubscribe_button));

        NotificationWrapper notification =
                buildNotificationWrapper(notificationBuilder, identifyingAttributes.notificationId);

        BaseNotificationManagerProxy notificationManager = createNotificationManagerProxy(context);
        notificationManager.notify(notification);
    }

    private void appendSiteSettingsButton(
            NotificationBuilderBase notificationBuilder,
            String notificationId,
            String origin,
            ActionInfo[] actions) {
        Context context = ContextUtils.getApplicationContext();
        Resources res = context.getResources();

        // TODO(peter): Generalize the NotificationPlatformBridge sufficiently to not need
        // to care about the individual notification types.
        // Set up a pending intent for going to the settings screen for |origin|.
        SettingsLauncher settingsLauncher = SettingsLauncherFactory.createSettingsLauncher();
        Intent settingsIntent =
                settingsLauncher.createSettingsActivityIntent(
                        context,
                        SingleWebsiteSettings.class,
                        SingleWebsiteSettings.createFragmentArgsForSite(origin));
        settingsIntent.setData(makeIntentData(notificationId, origin, /* actionIndex= */ -1));
        PendingIntentProvider settingsIntentProvider =
                PendingIntentProvider.getActivity(
                        context,
                        PENDING_INTENT_REQUEST_CODE,
                        settingsIntent,
                        PendingIntent.FLAG_UPDATE_CURRENT);

        // If action buttons are displayed, there isn't room for the full Site Settings button
        // label and icon, so abbreviate it. This has the unfortunate side-effect of
        // unnecessarily abbreviating it on Android Wear also (crbug.com/576656). If custom
        // layouts are enabled, the label and icon provided here only affect Android Wear, so
        // don't abbreviate them.
        boolean abbreviateSiteSettings = actions.length > 0;
        int settingsIconId = abbreviateSiteSettings ? 0 : R.drawable.settings_cog;
        CharSequence settingsTitle =
                abbreviateSiteSettings
                        ? res.getString(R.string.notification_site_settings_button)
                        : res.getString(R.string.page_info_site_settings_button);
        // If the settings button is displayed together with the other buttons it has to be the
        // last one, so add it after the other actions.
        notificationBuilder.addSettingsAction(
                settingsIconId,
                settingsTitle,
                settingsIntentProvider,
                NotificationUmaTracker.ActionType.SETTINGS);
    }

    private void appendUnsubscribeButton(
            NotificationBuilderBase notificationBuilder,
            NotificationIdentifyingAttributes identifyingAttributes) {
        PendingIntentProvider unsubscribeIntentProvider =
                makePendingIntent(
                        identifyingAttributes,
                        NotificationConstants.ACTION_PRE_UNSUBSCRIBE,
                        /* actionIndex= */ -1,
                        false);

        Context context = ContextUtils.getApplicationContext();
        Resources res = context.getResources();

        // TODO(crbug.com/41492613): Double check if this icon is actually used on any Android
        // versions and/or flavors.
        notificationBuilder.addSettingsAction(
                /* iconId= */ 0,
                res.getString(R.string.notification_unsubscribe_button),
                unsubscribeIntentProvider,
                NotificationUmaTracker.ActionType.PRE_UNSUBSCRIBE);
    }

    private static void addProvisionallyUnsubscribedNotificationAction(
            NotificationBuilderBase notificationBuilder,
            NotificationIdentifyingAttributes identifyingAttributes,
            String action,
            @NotificationUmaTracker.ActionType int umaActionType,
            CharSequence actionLabel) {
        PendingIntentProvider intentProvider =
                makePendingIntent(identifyingAttributes, action, /* actionIndex= */ -1, false);
        notificationBuilder.addSettingsAction(
                /* iconId= */ 0, actionLabel, intentProvider, umaActionType);
    }

    private static NotificationWrapper buildNotificationWrapper(
            NotificationBuilderBase notificationBuilder, String notificationId) {
        return notificationBuilder.build(
                new NotificationMetadata(
                        NotificationUmaTracker.SystemNotificationType.SITES,
                        /* notificationTag= */ notificationId,
                        /* notificationId= */ PLATFORM_ID));
    }

    /** Returns whether to set a channel id when building a notification. */
    private static boolean shouldSetChannelId(boolean forWebApk) {
        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !forWebApk;
    }

    /**
     * Creates the ticker text for a notification having |title| and |body|. The notification's
     * title will be printed in bold, followed by the text of the body.
     *
     * @param title Title of the notification.
     * @param body Textual contents of the notification.
     * @return A character sequence containing the ticker's text.
     */
    private static CharSequence createTickerText(String title, String body) {
        SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder();

        spannableStringBuilder.append(title);
        spannableStringBuilder.append("\n");
        spannableStringBuilder.append(body);

        // Mark the title of the notification as being bold.
        spannableStringBuilder.setSpan(
                new StyleSpan(android.graphics.Typeface.BOLD),
                0,
                title.length(),
                Spannable.SPAN_INCLUSIVE_INCLUSIVE);

        return spannableStringBuilder;
    }

    /**
     * Returns whether a notification has been clicked in the last 5 seconds.
     * Used for Startup.BringToForegroundReason UMA histogram.
     */
    public static boolean wasNotificationRecentlyClicked() {
        if (sInstance == null) return false;
        long now = System.currentTimeMillis();
        return now - sInstance.mLastNotificationClickMs < 5 * 1000;
    }

    /**
     * Closes the notification associated with the given parameters.
     *
     * @param notificationId The id of the notification.
     * @param scopeUrl The scope of the service worker registered by the site where the notification
     *                 comes from.
     * @param hasQueriedWebApkPackage Whether has done the query of is there a WebAPK can handle
     *                                this notification.
     * @param webApkPackage The package of the WebAPK associated with the notification.
     *                      Empty if the notification is not associated with a WebAPK.
     */
    @CalledByNative
    private void closeNotification(
            final String notificationId,
            String scopeUrl,
            boolean hasQueriedWebApkPackage,
            String webApkPackage) {
        if (!hasQueriedWebApkPackage) {
            final String webApkPackageFound =
                    WebApkValidator.queryFirstWebApkPackage(
                            ContextUtils.getApplicationContext(), scopeUrl);
            if (webApkPackageFound != null) {
                WebApkIdentityServiceClient.CheckBrowserBacksWebApkCallback callback =
                        new WebApkIdentityServiceClient.CheckBrowserBacksWebApkCallback() {
                            @Override
                            public void onChecked(
                                    boolean doesBrowserBackWebApk, String backingBrowser) {
                                closeNotificationInternal(
                                        notificationId,
                                        doesBrowserBackWebApk ? webApkPackageFound : null,
                                        scopeUrl);
                            }
                        };
                ChromeWebApkHost.checkChromeBacksWebApkAsync(webApkPackageFound, callback);
                return;
            }
        }
        closeNotificationInternal(notificationId, webApkPackage, scopeUrl);
    }

    /** Called after querying whether the browser backs the given WebAPK. */
    private void closeNotificationInternal(
            String notificationId, String webApkPackage, String scopeUrl) {
        if (!TextUtils.isEmpty(webApkPackage)) {
            WebApkServiceClient.getInstance()
                    .cancelNotification(webApkPackage, notificationId, PLATFORM_ID);
            return;
        }

        if (getTwaClient().twaExistsForScope(Uri.parse(scopeUrl))) {
            getTwaClient().cancelNotification(Uri.parse(scopeUrl), notificationId, PLATFORM_ID);

            // There's an edge case where a notification was displayed by Chrome, a Trusted Web
            // Activity is then installed and run then the notification is cancelled by javascript.
            // Chrome will attempt to close the notification through the TWA client and not itself.
            // Since NotificationManager#cancel is safe to call if the requested notification
            // isn't being shown, we just call that as well to ensure notifications are cleared.
        }

        // The "provisionally unsubscribed" service notification re-uses the tag of the organic
        // notification it has replaced. Do not let this service notification be canceled. If the
        // user clicks `UNDO_UNSUBSCRIBE`, we will still restore the cancelled notification for the
        // sake of tangibly demonstrating to the user that the unsubscribe action was undone.
        // TODO(crbug.com/359593412): The organic notification is at this point already deleted from
        // the NotificationDatabase in response to it being closed by the developer. If we end up
        // restoring it, user interactions other than "Unsubscribe" will not work. Fix this.
        String origin = getOriginFromNotificationTag(notificationId);
        if (origin != null && sOriginsWithProvisionallyRevokedPermissions.containsKey(origin)) {
            return;
        }

        mNotificationManager.cancel(notificationId, PLATFORM_ID);
    }

    /**
     * Calls NotificationPlatformBridgeAndroid::OnNotificationClicked in native code to indicate
     * that the notification with the given parameters has been clicked on.
     *
     * @param identifyingAttributes Common attributes identifying a notification and its source.
     * @param actionIndex The index of the action button that was clicked, or -1 if not applicable.
     * @param reply User reply to a text action on the notification. Null if the user did not click
     *     on a text action or if inline replies are not supported.
     */
    private void onNotificationClicked(
            NotificationIdentifyingAttributes identifyingAttributes,
            int actionIndex,
            @Nullable String reply) {
        // After the user taps the `PRE_UNSUBSCRIBE` action on a notification, they might, in quick
        // succession, tap the content or a developer-provided action button on the same or another
        // notification (in the short time window before these notifications get hidden). Given the
        // strong indication the user may want to stop getting these notifications, resolve this
        // conflict by silently discarding the action.
        if (identifyingAttributes.origin != null
                && sOriginsWithProvisionallyRevokedPermissions.containsKey(
                        identifyingAttributes.origin)) {
            return;
        }

        mLastNotificationClickMs = System.currentTimeMillis();
        NotificationPlatformBridgeJni.get()
                .onNotificationClicked(
                        mNativeNotificationPlatformBridge,
                        NotificationPlatformBridge.this,
                        identifyingAttributes.notificationId,
                        identifyingAttributes.notificationType,
                        identifyingAttributes.origin,
                        identifyingAttributes.scopeUrl,
                        identifyingAttributes.profileId,
                        identifyingAttributes.incognito,
                        identifyingAttributes.webApkPackage,
                        actionIndex,
                        reply);
    }

    /**
     * Calls NotificationPlatformBridgeAndroid::OnNotificationClosed in native code to indicate that
     * the notification with the given parameters has been closed.
     *
     * @param identifyingAttributes Common attributes identifying a notification and its source.
     * @param byUser Whether the notification was closed by a user gesture.
     */
    private void onNotificationClosed(
            NotificationIdentifyingAttributes identifyingAttributes, boolean byUser) {
        NotificationPlatformBridgeJni.get()
                .onNotificationClosed(
                        mNativeNotificationPlatformBridge,
                        NotificationPlatformBridge.this,
                        identifyingAttributes.notificationId,
                        identifyingAttributes.notificationType,
                        identifyingAttributes.origin,
                        identifyingAttributes.profileId,
                        identifyingAttributes.incognito,
                        byUser);
    }

    /**
     * Called when the user clicks the `ACTION_PRE_UNSUBSCRIBE` button.
     *
     * <p>Replaces the clicked notification with a "provisionally unsubscribed" service
     * notification. While that is showing, all new notifications from the origin are suspended, but
     * the permission is only revoked once it is dismissed/okay'ed/timed out.
     *
     * @param identifyingAttributes Common attributes identifying a notification and its source.
     */
    private static void onNotificationPreUnsubcribe(
            NotificationIdentifyingAttributes identifyingAttributes) {
        // Measure both real time, which includes CPU in power-saving modes and/or display going
        // dark; and uptime, which does not.
        long taskStartRealtimeMillis = SystemClock.elapsedRealtime();
        long taskStartUptimeMillis = SystemClock.uptimeMillis();

        // The user might tap on the PRE_UNSUBSCRIBE action multiple times if they are fast and/or
        // if the system is under load and it takes some time to dispatch the broadcast intent.
        // Record how often this happens and ignore duplicate unsubscribe actions.
        boolean duplicatePreUnsubscribe =
                sOriginsWithProvisionallyRevokedPermissions.containsKey(
                        identifyingAttributes.origin);
        NotificationUmaTracker.getInstance()
                .recordIsDuplicatePreUnsubscribe(duplicatePreUnsubscribe);
        if (duplicatePreUnsubscribe) {
            assert sLastPreUnsubscribePreNativeTaskStartRealMillis >= 0;
            NotificationUmaTracker.getInstance()
                    .recordDuplicatePreUnsubscribeRealDelay(
                            taskStartRealtimeMillis
                                    - sLastPreUnsubscribePreNativeTaskStartRealMillis);
            return;
        }

        var otherNotificationsBackups = new HashMap<String, Notification>();
        sOriginsWithProvisionallyRevokedPermissions.put(
                identifyingAttributes.origin, otherNotificationsBackups);
        sLastPreUnsubscribePreNativeTaskStartRealMillis = taskStartRealtimeMillis;

        Predicate<NotificationWrapper> isTappedNotification =
                (nw -> {
                    if (nw.getMetadata().id != PLATFORM_ID) return false;
                    return nw.getMetadata().tag.equals(identifyingAttributes.notificationId);
                });

        Context context = ContextUtils.getApplicationContext();
        var notificationManager = createNotificationManagerProxy(context);
        NotificationSuspender suspender =
                new NotificationSuspender(/* profile= */ null, context, notificationManager);
        suspender.getActiveNotificationsForOrigins(
                Collections.singletonList(Uri.parse(identifyingAttributes.origin)),
                (activeNotificationsForOrigin) -> {
                    NotificationUmaTracker.getInstance()
                            .recordSuspendedNotificationCountOnUnsubscribe(
                                    activeNotificationsForOrigin.size());

                    // This may be null if the user quickly dismissed the notification after
                    // clicking "Unsubscribe" but before this handler could run.
                    var tappedNotification =
                            activeNotificationsForOrigin.stream()
                                    .filter(isTappedNotification)
                                    .map(nw -> nw.getNotification())
                                    .findFirst()
                                    .orElse(null);

                    // TODO(crbug.com/360700866): This might theoretically exceed the transaction
                    // buffer size. Re-evaluate the pros/cons here once we have telemetry about the
                    // reliability of the alternative solution.
                    Bundle originalNotificationBackup = new Bundle();
                    originalNotificationBackup.putParcelable(
                            NotificationConstants.EXTRA_NOTIFICATION_BACKUP_OF_ORIGINAL,
                            tappedNotification);

                    displayProvisionallyUnsubscribedNotification(
                            identifyingAttributes, originalNotificationBackup);

                    otherNotificationsBackups.putAll(
                            activeNotificationsForOrigin.stream()
                                    .filter(nw -> !isTappedNotification.test(nw))
                                    .collect(
                                            Collectors.toMap(
                                                    nw -> nw.getMetadata().tag,
                                                    nw -> nw.getNotification())));
                    suspender.cancelNotificationsWithIds(
                            new ArrayList<String>(otherNotificationsBackups.keySet()));

                    NotificationUmaTracker.getInstance()
                            .recordPreUnsubscribeRealDuration(
                                    SystemClock.elapsedRealtime() - taskStartRealtimeMillis);
                    NotificationUmaTracker.getInstance()
                            .recordPreUnsubscribeDuration(
                                    SystemClock.uptimeMillis() - taskStartUptimeMillis);
                });
    }

    /**
     * Called when the user clicks the `ACTION_UNDO_UNSUBSCRIBE` button on the "provisionally
     * unsubscribed" service notification.
     *
     * <p>Restores the clicked notification and all other notifications from that origin.
     *
     * @param identifyingAttributes Common attributes identifying a notification and its source.
     */
    private static void onNotificationUndoUnsubscribe(
            NotificationIdentifyingAttributes identifyingAttributes) {
        var otherNotificationsBackups =
                sOriginsWithProvisionallyRevokedPermissions.remove(identifyingAttributes.origin);
        NotificationUmaTracker.getInstance()
                .recordWasGlobalStatePreserved(
                        NotificationUmaTracker.GlobalStatePreservedActionSuffix.UNDO,
                        otherNotificationsBackups != null);

        Predicate<BaseNotificationManagerProxy.StatusBarNotificationProxy> isTappedNotification =
                (sbn -> {
                    if (sbn.getId() != PLATFORM_ID) return false;
                    return sbn.getTag().equals(identifyingAttributes.notificationId);
                });

        Context context = ContextUtils.getApplicationContext();
        var notificationManager = createNotificationManagerProxy(context);
        notificationManager.getActiveNotifications(
                (activeNotifications) -> {
                    var tappedStatusBarNotification =
                            activeNotifications.stream()
                                    .filter(isTappedNotification)
                                    .findFirst()
                                    .orElse(null);
                    if (tappedStatusBarNotification == null) return;
                    var tappedNotificationExtras =
                            tappedStatusBarNotification.getNotification().extras;

                    // If the tapped notification does not have a backup key in the metadata, it is
                    // not a provisionally unsubscribed notification. Likely, the user clicked
                    // "Undo" twice in quick succession, and we are already done. Bail out.
                    if (!tappedNotificationExtras.containsKey(
                            NotificationConstants.EXTRA_NOTIFICATION_BACKUP_OF_ORIGINAL)) {
                        return;
                    }

                    var originalNotificationBackup =
                            (Notification)
                                    tappedNotificationExtras.getParcelable(
                                            NotificationConstants
                                                    .EXTRA_NOTIFICATION_BACKUP_OF_ORIGINAL);

                    // No backup means the original notification was quickly dismissed after the
                    // user clicked "Unsubscribe". In this case we still want to cancel the
                    // provisionally unsubscribed notification.
                    if (originalNotificationBackup == null) {
                        notificationManager.cancel(
                                identifyingAttributes.notificationId, PLATFORM_ID);
                    } else {
                        // Work around the following bug in Android: if setTimeoutAfter() is called
                        // on a Builder, then the corresponding notification shown, then cancelled,
                        // and then later another notification is shown with the same ID/tag without
                        // specify any timeout, the new notification will still "inherit" the
                        // original timeout. There is no way to specify "no timeout" other than
                        // specifying a sufficiently long timeout instead (e.g. one week).
                        //
                        // TODO(crbug.com/41494406): Find a more elegant solution to this problem.
                        Notification.Builder builder =
                                Notification.Builder.recoverBuilder(
                                        context, originalNotificationBackup);
                        builder.setTimeoutAfter(/* ms= */ 1000 * 3600 * 24 * 7);
                        builder.setGroupAlertBehavior(Notification.GROUP_ALERT_SUMMARY);
                        originalNotificationBackup = builder.build();

                        notificationManager.notify(
                                new NotificationWrapper(
                                        originalNotificationBackup,
                                        new NotificationMetadata(
                                                NotificationUmaTracker.SystemNotificationType.SITES,
                                                /* notificationTag= */ identifyingAttributes
                                                        .notificationId,
                                                /* notificationId= */ PLATFORM_ID)));
                    }

                    if (otherNotificationsBackups == null) return;

                    for (var entry : otherNotificationsBackups.entrySet()) {
                        Notification.Builder builder =
                                Notification.Builder.recoverBuilder(context, entry.getValue());
                        // Sound/vibration is controlled by NotificationChannels (as of Oreo), and
                        // calling `setDefaults`, `setSounds`, `setVibration` has no effect. These
                        // "other" notifications we are restoring here are also not considered by
                        // Android to "renotify" cases, so `setOnlyAlertOnce` works neither.
                        //
                        // However, an effective way of silencing re-showing these notifications is
                        // to configure that sound/vibration be only played for the group summary.
                        // This works because these notifications are put in groups by origin, but
                        // every one of them marked as group children (and there is no summary).
                        builder.setGroupAlertBehavior(Notification.GROUP_ALERT_SUMMARY);
                        notificationManager.notify(
                                new NotificationWrapper(
                                        builder.build(),
                                        new NotificationMetadata(
                                                NotificationUmaTracker.SystemNotificationType.SITES,
                                                /* notificationTag= */ entry.getKey(),
                                                /* notificationId= */ PLATFORM_ID)));
                    }
                });
    }

    /**
     * Called when the user clicks the `ACTION_COMMIT_UNSUBSCRIBE` button, expressly dismisses the
     * "provisionally unsubscribed" service notification, or if the service notification times out.
     *
     * <p>Handles "unsubscribing", which in practice means resetting the permission for the origin,
     * which will delete the notification channel, issue an FCM unsubscribe request, and cancel all
     * notification, including the "Provisionally unsubscribed" service notification.
     *
     * @param identifyingAttributes Common attributes identifying a notification and its source.
     */
    private void onNotificationCommitUnsubscribe(
            NotificationIdentifyingAttributes identifyingAttributes) {
        NotificationPlatformBridgeJni.get()
                .onNotificationDisablePermission(
                        mNativeNotificationPlatformBridge,
                        NotificationPlatformBridge.this,
                        identifyingAttributes.notificationId,
                        identifyingAttributes.notificationType,
                        identifyingAttributes.origin,
                        identifyingAttributes.profileId,
                        identifyingAttributes.incognito);
        var backups =
                sOriginsWithProvisionallyRevokedPermissions.remove(identifyingAttributes.origin);
        NotificationUmaTracker.getInstance()
                .recordWasGlobalStatePreserved(
                        NotificationUmaTracker.GlobalStatePreservedActionSuffix.COMMIT,
                        backups != null);
    }

    private TrustedWebActivityClient getTwaClient() {
        if (mTwaClient == null) {
            mTwaClient = ChromeApplicationImpl.getComponent().resolveTrustedWebActivityClient();
        }
        return mTwaClient;
    }

    @NativeMethods
    interface Natives {
        void initializeNotificationPlatformBridge();

        void onNotificationClicked(
                long nativeNotificationPlatformBridgeAndroid,
                NotificationPlatformBridge caller,
                @JniType("std::string") String notificationId,
                @NotificationType int notificationType,
                @JniType("std::string") String origin,
                @JniType("std::string") String scopeUrl,
                @JniType("std::string") String profileId,
                boolean incognito,
                @JniType("std::string") String webApkPackage,
                int actionIndex,
                String reply);

        void onNotificationClosed(
                long nativeNotificationPlatformBridgeAndroid,
                NotificationPlatformBridge caller,
                @JniType("std::string") String notificationId,
                @NotificationType int notificationType,
                @JniType("std::string") String origin,
                @JniType("std::string") String profileId,
                boolean incognito,
                boolean byUser);

        void onNotificationDisablePermission(
                long nativeNotificationPlatformBridgeAndroid,
                NotificationPlatformBridge caller,
                @JniType("std::string") String notificationId,
                @NotificationType int notificationType,
                @JniType("std::string") String origin,
                @JniType("std::string") String profileId,
                boolean incognito);

        void storeCachedWebApkPackageForNotificationId(
                long nativeNotificationPlatformBridgeAndroid,
                NotificationPlatformBridge caller,
                @JniType("std::string") String notificationId,
                @JniType("std::string") String webApkPackage);
    }
}