blob: f0d824eeb0027894f89ef7d5f7fa76676f928e00 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// 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 android.app.Notification;
import android.app.NotificationManager;
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.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.library_loader.ProcessInitException;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeFeatureList;
import org.chromium.chrome.browser.browserservices.TrustedWebActivityClient;
import org.chromium.chrome.browser.init.ChromeBrowserInitializer;
import org.chromium.chrome.browser.notifications.channels.ChannelDefinitions;
import org.chromium.chrome.browser.notifications.channels.SiteChannelsManager;
import org.chromium.chrome.browser.preferences.PrefServiceBridge;
import org.chromium.chrome.browser.preferences.Preferences;
import org.chromium.chrome.browser.preferences.PreferencesLauncher;
import org.chromium.chrome.browser.preferences.website.SingleCategoryPreferences;
import org.chromium.chrome.browser.preferences.website.SingleWebsitePreferences;
import org.chromium.chrome.browser.preferences.website.SiteSettingsCategory;
import org.chromium.chrome.browser.webapps.ChromeWebApkHost;
import org.chromium.chrome.browser.webapps.WebApkServiceClient;
import org.chromium.components.url_formatter.UrlFormatter;
import org.chromium.webapk.lib.client.WebApkIdentityServiceClient;
import org.chromium.webapk.lib.client.WebApkValidator;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nullable;
/**
* Provides the ability for the NotificationPlatformBridgeAndroid to talk to the Android platform
* notification system.
*
* 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.
@VisibleForTesting 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];
private static NotificationPlatformBridge sInstance;
private static NotificationManagerProxy sNotificationManagerOverride;
private final long mNativeNotificationPlatformBridge;
private final NotificationManagerProxy mNotificationManager;
private long mLastNotificationClickMs;
private TrustedWebActivityClient mTwaClient;
/**
* 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
@VisibleForTesting
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.
*/
@VisibleForTesting
static void overrideNotificationManagerForTesting(
NotificationManagerProxy notificationManager) {
sNotificationManagerOverride = notificationManager;
}
private NotificationPlatformBridge(long nativeNotificationPlatformBridge) {
mNativeNotificationPlatformBridge = nativeNotificationPlatformBridge;
Context context = ContextUtils.getApplicationContext();
if (sNotificationManagerOverride != null) {
mNotificationManager = sNotificationManagerOverride;
} else {
mNotificationManager = new NotificationManagerProxyImpl(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
}
mTwaClient = new TrustedWebActivityClient(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 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) {
nativeInitializeNotificationPlatformBridge();
if (sInstance == null) {
Log.e(TAG, "Unable to initialize the native NotificationPlatformBridge.");
return false;
}
}
recordJobStartDelayUMA(intent);
String notificationId = intent.getStringExtra(NotificationConstants.EXTRA_NOTIFICATION_ID);
String origin = intent.getStringExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_ORIGIN);
String scopeUrl =
intent.getStringExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_SCOPE);
if (scopeUrl == null) scopeUrl = "";
String profileId =
intent.getStringExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_PROFILE_ID);
boolean incognito = intent.getBooleanExtra(
NotificationConstants.EXTRA_NOTIFICATION_INFO_PROFILE_INCOGNITO, false);
Log.i(TAG, "Dispatching notification event to native: " + notificationId);
if (NotificationConstants.ACTION_CLICK_NOTIFICATION.equals(intent.getAction())) {
String webApkPackage = intent.getStringExtra(
NotificationConstants.EXTRA_NOTIFICATION_INFO_WEBAPK_PACKAGE);
if (webApkPackage == null) {
webApkPackage = "";
}
int actionIndex = intent.getIntExtra(
NotificationConstants.EXTRA_NOTIFICATION_INFO_ACTION_INDEX, -1);
sInstance.onNotificationClicked(notificationId, origin, scopeUrl, profileId, incognito,
webApkPackage, 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(
notificationId, origin, profileId, incognito, true /* byUser */);
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, TimeUnit.MILLISECONDS);
}
}
@Nullable
static String getNotificationReply(Intent intent) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
// RemoteInput was added in KITKAT_WATCH.
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 context The context that received the intent.
* @param incomingIntent The received intent.
*/
public static void launchNotificationPreferences(Context context, 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.
try {
ChromeBrowserInitializer.getInstance(context).handleSynchronousStartup();
} catch (ProcessInitException e) {
Log.e(TAG, "Failed to start browser process.", e);
// The library failed to initialize and nothing in the application can work, so kill
// the whole application.
System.exit(-1);
return;
}
// 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 = context.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;
String fragmentName = launchSingleWebsitePreferences
? SingleWebsitePreferences.class.getName()
: SingleCategoryPreferences.class.getName();
Intent preferencesIntent =
PreferencesLauncher.createIntentForSettingsPage(applicationContext, fragmentName);
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 = SingleWebsitePreferences.createFragmentArgsForSite(origin);
} else {
// Notification preferences for all origins.
fragmentArguments = new Bundle();
fragmentArguments.putString(SingleCategoryPreferences.EXTRA_CATEGORY,
SiteSettingsCategory.CATEGORY_NOTIFICATIONS);
fragmentArguments.putString(SingleCategoryPreferences.EXTRA_TITLE,
applicationContext.getResources().getString(
R.string.push_notifications_permission_title));
}
preferencesIntent.putExtra(Preferences.EXTRA_SHOW_FRAGMENT_ARGUMENTS, fragmentArguments);
applicationContext.startActivity(preferencesIntent);
}
/**
* 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 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.
*
* All parameters set here should also be set in
* {@link NotificationJobService#getJobExtrasFromIntent(Intent)}.
*
* @param context An appropriate context for the intent class and broadcast.
* @param action The action this pending intent will represent.
* @param notificationId The id of the notification.
* @param origin The origin to whom the notification belongs.
* @param scopeUrl The scope of the service worker registered by the site where the notification
* comes from.
* @param profileId Id of the profile to which the notification belongs.
* @param incognito Whether the profile was in incognito mode.
* @param webApkPackage The package of the WebAPK associated with the notification. Empty if
* the notification is not associated with a WebAPK.
* @param actionIndex The zero-based index of the action button, or -1 if not applicable.
*/
private PendingIntent makePendingIntent(Context context, String action, String notificationId,
String origin, String scopeUrl, String profileId, boolean incognito,
String webApkPackage, int actionIndex) {
Uri intentData = makeIntentData(notificationId, origin, actionIndex);
Intent intent = new Intent(action, intentData);
intent.setClass(context, NotificationService.Receiver.class);
// Make sure to update NotificationJobService.getJobExtrasFromIntent() when changing any
// of the extras included with the |intent|.
intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_ID, notificationId);
intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_ORIGIN, origin);
intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_SCOPE, scopeUrl);
intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_PROFILE_ID, profileId);
intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_PROFILE_INCOGNITO, incognito);
intent.putExtra(
NotificationConstants.EXTRA_NOTIFICATION_INFO_WEBAPK_PACKAGE, 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 on N+.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
}
return PendingIntent.getBroadcast(
context, PENDING_INTENT_REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
/**
* 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.
*/
@Nullable
private static 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.
*/
@Nullable
@VisibleForTesting
static 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(https://crbug.com/801164): 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
|| !channelId.startsWith(ChannelDefinitions.CHANNEL_ID_PREFIX_SITES)) {
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 incognito if the session of the profile is an off the record one.
* @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(final String notificationId, final String origin,
final String scopeUrl, final String profileId, final boolean incognito,
final String title, final String body, final Bitmap image, final Bitmap icon,
final Bitmap badge, final int[] vibrationPattern, final long timestamp,
final boolean renotify, final boolean silent, final ActionInfo[] actions) {
final String webApkPackage =
WebApkValidator.queryWebApkPackage(ContextUtils.getApplicationContext(), scopeUrl);
if (webApkPackage != null) {
WebApkIdentityServiceClient.CheckBrowserBacksWebApkCallback callback =
new WebApkIdentityServiceClient.CheckBrowserBacksWebApkCallback() {
@Override
public void onChecked(boolean doesBrowserBackWebApk) {
displayNotificationInternal(notificationId, origin, scopeUrl, profileId,
incognito, title, body, image, icon, badge, vibrationPattern,
timestamp, renotify, silent, actions,
doesBrowserBackWebApk ? webApkPackage : "");
}
};
ChromeWebApkHost.checkChromeBacksWebApkAsync(webApkPackage, callback);
return;
}
displayNotificationInternal(notificationId, origin, scopeUrl, profileId, incognito, title,
body, image, icon, badge, vibrationPattern, timestamp, renotify, silent, actions,
"");
}
/** Called after querying whether the browser backs the given WebAPK. */
private void displayNotificationInternal(String notificationId, String origin, String scopeUrl,
String profileId, boolean incognito, String title, String body, Bitmap image,
Bitmap icon, Bitmap badge, int[] vibrationPattern, long timestamp, boolean renotify,
boolean silent, ActionInfo[] actions, String webApkPackage) {
nativeStoreCachedWebApkPackageForNotificationId(
mNativeNotificationPlatformBridge, notificationId, webApkPackage);
Context context = ContextUtils.getApplicationContext();
Resources res = context.getResources();
// Record whether it's known whether notifications can be shown to the user at all.
RecordHistogram.recordEnumeratedHistogram("Notifications.AppNotificationStatus",
NotificationSystemStatusUtil.determineAppNotificationStatus(context),
NotificationSystemStatusUtil.APP_NOTIFICATIONS_STATUS_BOUNDARY);
PendingIntent clickIntent = makePendingIntent(context,
NotificationConstants.ACTION_CLICK_NOTIFICATION, notificationId, origin, scopeUrl,
profileId, incognito, webApkPackage, -1 /* actionIndex */);
PendingIntent closeIntent = makePendingIntent(context,
NotificationConstants.ACTION_CLOSE_NOTIFICATION, notificationId, origin, scopeUrl,
profileId, incognito, webApkPackage, -1 /* actionIndex */);
boolean hasImage = image != null;
boolean forWebApk = !webApkPackage.isEmpty();
NotificationBuilderBase notificationBuilder =
createNotificationBuilder(context, hasImage)
.setTitle(title)
.setBody(body)
.setImage(image)
.setLargeIcon(icon)
.setSmallIcon(R.drawable.ic_chrome)
.setSmallIcon(badge)
.setContentIntent(clickIntent)
.setDeleteIntent(closeIntent)
.setTicker(createTickerText(title, body))
.setTimestamp(timestamp)
.setRenotify(renotify)
.setOrigin(UrlFormatter.formatUrlForSecurityDisplay(
origin, false /* showScheme */));
if (shouldSetChannelId(forWebApk)) {
// TODO(crbug.com/700377): Channel ID should be retrieved from cache in native and
// passed through to here with other notification parameters.
String channelId =
ChromeFeatureList.isEnabled(ChromeFeatureList.SITE_NOTIFICATION_CHANNELS)
? SiteChannelsManager.getInstance().getChannelIdForOrigin(origin)
: ChannelDefinitions.CHANNEL_ID_SITES;
notificationBuilder.setChannelId(channelId);
}
for (int actionIndex = 0; actionIndex < actions.length; actionIndex++) {
PendingIntent intent = makePendingIntent(context,
NotificationConstants.ACTION_CLICK_NOTIFICATION, notificationId, origin,
scopeUrl, profileId, incognito, webApkPackage, actionIndex);
ActionInfo action = actions[actionIndex];
// 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.
boolean vibrateEnabled = PrefServiceBridge.getInstance().isNotificationsVibrateEnabled();
if (!vibrateEnabled) {
vibrationPattern = EMPTY_VIBRATION_PATTERN;
}
notificationBuilder.setDefaults(
makeDefaults(vibrationPattern.length, silent, vibrateEnabled));
notificationBuilder.setVibrate(makeVibrationPattern(vibrationPattern));
if (forWebApk) {
WebApkServiceClient.getInstance().notifyNotification(
webApkPackage, notificationBuilder, notificationId, PLATFORM_ID);
} else if (mTwaClient.twaExistsForScope(Uri.parse(scopeUrl))) {
mTwaClient.notifyNotification(Uri.parse(scopeUrl), notificationId, PLATFORM_ID,
notificationBuilder);
} else {
// Set up a pending intent for going to the settings screen for |origin|.
Intent settingsIntent = PreferencesLauncher.createIntentForSettingsPage(
context, SingleWebsitePreferences.class.getName());
settingsIntent.setData(makeIntentData(notificationId, origin, -1 /* actionIndex */));
settingsIntent.putExtra(Preferences.EXTRA_SHOW_FRAGMENT_ARGUMENTS,
SingleWebsitePreferences.createFragmentArgsForSite(origin));
PendingIntent pendingSettingsIntent = PendingIntent.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 && !useCustomLayouts(hasImage);
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, pendingSettingsIntent);
mNotificationManager.notify(notificationId, PLATFORM_ID, notificationBuilder.build());
NotificationUmaTracker.getInstance().onNotificationShown(
NotificationUmaTracker.SITES, notificationBuilder.mChannelId);
}
}
private NotificationBuilderBase createNotificationBuilder(Context context, boolean hasImage) {
if (useCustomLayouts(hasImage)) {
return new CustomNotificationBuilder(context);
}
return new StandardNotificationBuilder(context);
}
/** Returns whether to set a channel id when building a notification. */
private 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 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;
}
/**
* Determines whether to use standard notification layouts, using NotificationCompat.Builder,
* or custom layouts using Chrome's own templates.
*
* Normally a standard layout is used on Android N+, and a custom layout is used on older
* versions of Android. But if the notification has a content image, there isn't enough room for
* the Site Settings button to go on its own line when showing an image, nor is there enough
* room for action button icons, so a standard layout will be used here even on old versions.
*
* @param hasImage Whether the notification has a content image.
* @return Whether custom layouts should be used.
*/
@VisibleForTesting
static boolean useCustomLayouts(boolean hasImage) {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.N && !hasImage;
}
/**
* 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.queryWebApkPackage(
ContextUtils.getApplicationContext(), scopeUrl);
if (webApkPackageFound != null) {
WebApkIdentityServiceClient.CheckBrowserBacksWebApkCallback callback =
new WebApkIdentityServiceClient.CheckBrowserBacksWebApkCallback() {
@Override
public void onChecked(boolean doesBrowserBackWebApk) {
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 (mTwaClient.twaExistsForScope(Uri.parse(scopeUrl))) {
mTwaClient.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.
}
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 notificationId The id of the notification.
* @param origin The origin of the 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 incognito if the profile session was an off the record one.
* @param webApkPackage The package of the WebAPK associated with the notification.
* Empty if the notification is not associated with a WebAPK.
* @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(String notificationId, String origin, String scopeUrl,
String profileId, boolean incognito, String webApkPackage, int actionIndex,
@Nullable String reply) {
mLastNotificationClickMs = System.currentTimeMillis();
nativeOnNotificationClicked(mNativeNotificationPlatformBridge, notificationId, origin,
scopeUrl, profileId, incognito, webApkPackage, actionIndex, reply);
}
/**
* Calls NotificationPlatformBridgeAndroid::OnNotificationClosed in native code to indicate that
* the notification with the given parameters has been closed.
*
* @param notificationId The id of the notification.
* @param origin The origin of the notification.
* @param profileId Id of the profile that showed the notification.
* @param incognito if the profile session was an off the record one.
* @param byUser Whether the notification was closed by a user gesture.
*/
private void onNotificationClosed(String notificationId, String origin, String profileId,
boolean incognito, boolean byUser) {
nativeOnNotificationClosed(mNativeNotificationPlatformBridge, notificationId, origin,
profileId, incognito, byUser);
}
private static native void nativeInitializeNotificationPlatformBridge();
private native void nativeOnNotificationClicked(long nativeNotificationPlatformBridgeAndroid,
String notificationId, String origin, String scopeUrl, String profileId,
boolean incognito, String webApkPackage, int actionIndex, String reply);
private native void nativeOnNotificationClosed(long nativeNotificationPlatformBridgeAndroid,
String notificationId, String origin, String profileId, boolean incognito,
boolean byUser);
private native void nativeStoreCachedWebApkPackageForNotificationId(
long nativeNotificationPlatformBridgeAndroid, String notificationId,
String webApkPackage);
}