blob: 109b7e7360bc5ea63641cc5c60611b703b068227 [file] [log] [blame]
// Copyright 2016 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.ntp;
import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Build;
import android.provider.Browser;
import org.chromium.base.ContextUtils;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.library_loader.LibraryProcessType;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.IntentHandler;
import org.chromium.chrome.browser.ShortcutHelper;
import org.chromium.chrome.browser.document.ChromeLauncherActivity;
import org.chromium.chrome.browser.notifications.ChromeNotification;
import org.chromium.chrome.browser.notifications.ChromeNotificationBuilder;
import org.chromium.chrome.browser.notifications.NotificationBuilderFactory;
import org.chromium.chrome.browser.notifications.NotificationManagerProxy;
import org.chromium.chrome.browser.notifications.NotificationManagerProxyImpl;
import org.chromium.chrome.browser.notifications.NotificationMetadata;
import org.chromium.chrome.browser.notifications.NotificationUmaTracker;
import org.chromium.chrome.browser.notifications.PendingIntentProvider;
import org.chromium.chrome.browser.notifications.channels.ChannelDefinitions;
import org.chromium.chrome.browser.notifications.channels.ChannelsInitializer;
import org.chromium.chrome.browser.ntp.snippets.ContentSuggestionsNotificationAction;
import org.chromium.chrome.browser.ntp.snippets.ContentSuggestionsNotificationOptOut;
import org.chromium.chrome.browser.preferences.NotificationsPreferences;
import org.chromium.chrome.browser.preferences.PreferencesLauncher;
import org.chromium.content_public.browser.BrowserStartupController;
import org.chromium.content_public.browser.BrowserStartupController.StartupCallback;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/**
* Provides functionality needed for content suggestion notifications.
*
* Exposes helper functions to native C++ code.
*/
public class ContentSuggestionsNotifier {
private static final String NOTIFICATION_TAG = "ContentSuggestionsNotification";
private static final String NOTIFICATION_ID_EXTRA = "notification_id";
private static final String NOTIFICATION_CATEGORY_EXTRA = "category";
private static final String NOTIFICATION_ID_WITHIN_CATEGORY_EXTRA = "id_within_category";
private static final String PREF_CHANNEL_CREATED =
"ntp.content_suggestions.notification.channel_created";
private static final String PREF_CACHED_ACTION_TAP =
"ntp.content_suggestions.notification.cached_action_tap";
private static final String PREF_CACHED_ACTION_DISMISSAL =
"ntp.content_suggestions.notification.cached_action_dismissal";
private static final String PREF_CACHED_ACTION_HIDE_DEADLINE =
"ntp.content_suggestions.notification.cached_action_hide_deadline";
private static final String PREF_CACHED_ACTION_HIDE_EXPIRY =
"ntp.content_suggestions.notification.cached_action_hide_expiry";
private static final String PREF_CACHED_ACTION_HIDE_FRONTMOST =
"ntp.content_suggestions.notification.cached_action_hide_frontmost";
private static final String PREF_CACHED_ACTION_HIDE_DISABLED =
"ntp.content_suggestions.notification.cached_action_hide_disabled";
private static final String PREF_CACHED_ACTION_HIDE_SHUTDOWN =
"ntp.content_suggestions.notification.cached_action_hide_shutdown";
private static final String PREF_CACHED_CONSECUTIVE_IGNORED =
"ntp.content_suggestions.notification.cached_consecutive_ignored";
// Tracks which URIs there is an active notification for.
private static final String PREF_ACTIVE_NOTIFICATIONS =
"ntp.content_suggestions.notification.active";
private ContentSuggestionsNotifier() {} // Prevent instantiation
/**
* Records the reason why Content Suggestions notifications have been opted out.
* @see ContentSuggestionsNotificationOptOut;
*/
public static void recordNotificationOptOut(@ContentSuggestionsNotificationOptOut int reason) {
nativeRecordNotificationOptOut(reason);
}
/**
* Records an action performed on a Content Suggestions notification.
* @see ContentSuggestionsNotificationAction;
*/
public static void recordNotificationAction(@ContentSuggestionsNotificationAction int action) {
nativeRecordNotificationAction(action);
}
/**
* Opens the content suggestion when notification is tapped.
*/
public static final class OpenUrlReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
int category = intent.getIntExtra(NOTIFICATION_CATEGORY_EXTRA, -1);
String idWithinCategory = intent.getStringExtra(NOTIFICATION_ID_WITHIN_CATEGORY_EXTRA);
openUrl(intent.getData());
hideNotification(category, idWithinCategory, ContentSuggestionsNotificationAction.TAP);
}
}
/**
* Records dismissal when notification is swiped away.
*/
public static final class DeleteReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
int category = intent.getIntExtra(NOTIFICATION_CATEGORY_EXTRA, -1);
String idWithinCategory = intent.getStringExtra(NOTIFICATION_ID_WITHIN_CATEGORY_EXTRA);
if (removeActiveNotification(category, idWithinCategory)) {
recordCachedActionMetric(ContentSuggestionsNotificationAction.DISMISSAL);
}
}
}
/**
* Removes the notification after a timeout period.
*/
public static final class TimeoutReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
int category = intent.getIntExtra(NOTIFICATION_CATEGORY_EXTRA, -1);
String idWithinCategory = intent.getStringExtra(NOTIFICATION_ID_WITHIN_CATEGORY_EXTRA);
hideNotification(
category, idWithinCategory, ContentSuggestionsNotificationAction.HIDE_DEADLINE);
}
}
private static void openUrl(Uri uri) {
Context context = ContextUtils.getApplicationContext();
Intent intent = new Intent()
.setAction(Intent.ACTION_VIEW)
.setData(uri)
.setClass(context, ChromeLauncherActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName())
.putExtra(ShortcutHelper.REUSE_URL_MATCHING_TAB_ELSE_NEW_TAB, true);
IntentHandler.addTrustedIntentExtras(intent);
context.startActivity(intent);
}
@CalledByNative
private static boolean showNotification(int category, String idWithinCategory, String url,
String title, String text, Bitmap image, long timeoutAtMillis, int priority) {
if (findActiveNotification(category, idWithinCategory) != null) return false;
// Post notification.
Context context = ContextUtils.getApplicationContext();
NotificationManagerProxy manager = new NotificationManagerProxyImpl(context);
int nextId = nextNotificationId();
Uri uri = Uri.parse(url);
PendingIntentProvider contentIntent = PendingIntentProvider.getBroadcast(context, 0,
new Intent(context, OpenUrlReceiver.class)
.setData(uri)
.putExtra(NOTIFICATION_CATEGORY_EXTRA, category)
.putExtra(NOTIFICATION_ID_WITHIN_CATEGORY_EXTRA, idWithinCategory),
0);
PendingIntentProvider deleteIntent = PendingIntentProvider.getBroadcast(context, 0,
new Intent(context, DeleteReceiver.class)
.setData(uri)
.putExtra(NOTIFICATION_CATEGORY_EXTRA, category)
.putExtra(NOTIFICATION_ID_WITHIN_CATEGORY_EXTRA, idWithinCategory),
0);
ChromeNotificationBuilder builder =
NotificationBuilderFactory
.createChromeNotificationBuilder(true /* preferCompat */,
ChannelDefinitions.ChannelId.CONTENT_SUGGESTIONS,
null /* remoteAppPackageName */,
new NotificationMetadata(
NotificationUmaTracker.SystemNotificationType
.CONTENT_SUGGESTION,
NOTIFICATION_TAG, nextId))
.setContentIntent(contentIntent)
.setDeleteIntent(deleteIntent)
.setContentTitle(title)
.setContentText(text)
.setGroup(NOTIFICATION_TAG)
.setPriorityBeforeO(priority)
.setLargeIcon(image)
.setSmallIcon(R.drawable.ic_chrome);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
PendingIntentProvider settingsIntent = PendingIntentProvider.getActivity(context, 0,
PreferencesLauncher.createIntentForSettingsPage(
context, NotificationsPreferences.class.getName()),
0);
builder.addAction(R.drawable.settings_cog, context.getString(R.string.preferences),
settingsIntent, NotificationUmaTracker.ActionType.CONTENT_SUGGESTION_SETTINGS);
}
if (priority >= 0) builder.setDefaults(Notification.DEFAULT_ALL);
ChromeNotification notification = builder.buildChromeNotification();
manager.notify(notification);
NotificationUmaTracker.getInstance().onNotificationShown(
NotificationUmaTracker.SystemNotificationType.CONTENT_SUGGESTION,
notification.getNotification());
addActiveNotification(new ActiveNotification(nextId, category, idWithinCategory, uri));
// Set timeout.
if (timeoutAtMillis != Long.MAX_VALUE) {
AlarmManager alarmManager =
(AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
Intent timeoutIntent =
new Intent(context, TimeoutReceiver.class)
.setData(Uri.parse(url))
.putExtra(NOTIFICATION_ID_EXTRA, nextId)
.putExtra(NOTIFICATION_CATEGORY_EXTRA, category)
.putExtra(NOTIFICATION_ID_WITHIN_CATEGORY_EXTRA, idWithinCategory);
alarmManager.set(AlarmManager.RTC, timeoutAtMillis,
PendingIntent.getBroadcast(
context, 0, timeoutIntent, PendingIntent.FLAG_UPDATE_CURRENT));
}
return true;
}
/**
* Hides a notification and records an action to the Actions histogram.
*
* If the notification is not actually visible, then no action will be taken, and the action
* will not be recorded.
*/
@CalledByNative
private static void hideNotification(int category, String idWithinCategory, int why) {
ActiveNotification activeNotification = findActiveNotification(category, idWithinCategory);
if (!removeActiveNotification(category, idWithinCategory)) return;
Context context = ContextUtils.getApplicationContext();
NotificationManager manager =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
manager.cancel(NOTIFICATION_TAG, activeNotification.mId);
recordCachedActionMetric(why);
}
@CalledByNative
private static void hideAllNotifications(int why) {
Context context = ContextUtils.getApplicationContext();
NotificationManager manager =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
for (ActiveNotification activeNotification : getActiveNotifications()) {
manager.cancel(NOTIFICATION_TAG, activeNotification.mId);
if (removeActiveNotification(
activeNotification.mCategory, activeNotification.mIdWithinCategory)) {
recordCachedActionMetric(why);
}
}
assert getActiveNotifications().isEmpty();
}
private static class ActiveNotification {
final int mId;
final int mCategory;
final String mIdWithinCategory;
final Uri mUri;
ActiveNotification(int id, int category, String idWithinCategory, Uri uri) {
mId = id;
mCategory = category;
mIdWithinCategory = idWithinCategory;
mUri = uri;
}
/** Parses the fields out of a chrome://content-suggestions-notification URI */
static ActiveNotification fromUri(Uri notificationUri) {
assert notificationUri.getScheme().equals("chrome");
assert notificationUri.getAuthority().equals("content-suggestions-notification");
assert notificationUri.getQueryParameter("id") != null;
assert notificationUri.getQueryParameter("category") != null;
assert notificationUri.getQueryParameter("idWithinCategory") != null;
assert notificationUri.getQueryParameter("uri") != null;
return new ActiveNotification(Integer.parseInt(notificationUri.getQueryParameter("id")),
Integer.parseInt(notificationUri.getQueryParameter("category")),
notificationUri.getQueryParameter("idWithinCategory"),
Uri.parse(notificationUri.getQueryParameter("uri")));
}
/** Serializes the fields to a chrome://content-suggestions-notification URI */
Uri toUri() {
return new Uri.Builder()
.scheme("chrome")
.authority("content-suggestions-notification")
.appendQueryParameter("id", Integer.toString(mId))
.appendQueryParameter("category", Integer.toString(mCategory))
.appendQueryParameter("idWithinCategory", mIdWithinCategory)
.appendQueryParameter("uri", mUri.toString())
.build();
}
}
/** Returns a mutable copy of the named pref. Never returns null. */
private static Set<String> getMutableStringSetPreference(
SharedPreferences prefs, String prefName) {
Set<String> prefValue = prefs.getStringSet(prefName, null);
if (prefValue == null) {
return new HashSet<String>();
}
return new HashSet<String>(prefValue);
}
/** Adds notification to the "active" set. */
private static void addActiveNotification(ActiveNotification notification) {
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
Set<String> activeNotifications =
getMutableStringSetPreference(prefs, PREF_ACTIVE_NOTIFICATIONS);
activeNotifications.add(notification.toUri().toString());
prefs.edit().putStringSet(PREF_ACTIVE_NOTIFICATIONS, activeNotifications).apply();
}
/** Removes notification from the "active" set. Returns false if it wasn't there. */
private static boolean removeActiveNotification(int category, String idWithinCategory) {
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
ActiveNotification notification = findActiveNotification(category, idWithinCategory);
if (notification == null) return false;
Set<String> activeNotifications =
getMutableStringSetPreference(prefs, PREF_ACTIVE_NOTIFICATIONS);
boolean result = activeNotifications.remove(notification.toUri().toString());
prefs.edit().putStringSet(PREF_ACTIVE_NOTIFICATIONS, activeNotifications).apply();
return result;
}
private static Collection<ActiveNotification> getActiveNotifications() {
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
Set<String> activeNotifications = prefs.getStringSet(PREF_ACTIVE_NOTIFICATIONS, null);
if (activeNotifications == null) return Collections.emptySet();
Set<ActiveNotification> result = new HashSet<ActiveNotification>();
for (String serialized : activeNotifications) {
Uri notificationUri = Uri.parse(serialized);
ActiveNotification activeNotification = ActiveNotification.fromUri(notificationUri);
if (activeNotification != null) result.add(activeNotification);
}
return result;
}
/** Returns an ActiveNotification if a corresponding one is found, otherwise null. */
private static ActiveNotification findActiveNotification(
int category, String idWithinCategory) {
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
Set<String> activeNotifications = prefs.getStringSet(PREF_ACTIVE_NOTIFICATIONS, null);
if (activeNotifications == null) return null;
for (String serialized : activeNotifications) {
Uri notificationUri = Uri.parse(serialized);
ActiveNotification activeNotification = ActiveNotification.fromUri(notificationUri);
if ((activeNotification != null) && (activeNotification.mCategory == category)
&& (activeNotification.mIdWithinCategory.equals(idWithinCategory))) {
return activeNotification;
}
}
return null;
}
/** Returns a non-negative integer greater than any active notification's notification ID. */
private static int nextNotificationId() {
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
Set<String> activeNotifications = prefs.getStringSet(PREF_ACTIVE_NOTIFICATIONS, null);
if (activeNotifications == null) return 0;
int nextId = 0;
for (String serialized : activeNotifications) {
Uri notificationUri = Uri.parse(serialized);
ActiveNotification activeNotification = ActiveNotification.fromUri(notificationUri);
if ((activeNotification != null) && (activeNotification.mId >= nextId)) {
nextId = activeNotification.mId + 1;
}
}
return nextId;
}
private static String cachedMetricNameForAction(
@ContentSuggestionsNotificationAction int action) {
switch (action) {
case ContentSuggestionsNotificationAction.TAP:
return PREF_CACHED_ACTION_TAP;
case ContentSuggestionsNotificationAction.DISMISSAL:
return PREF_CACHED_ACTION_DISMISSAL;
case ContentSuggestionsNotificationAction.HIDE_DEADLINE:
return PREF_CACHED_ACTION_HIDE_DEADLINE;
case ContentSuggestionsNotificationAction.HIDE_EXPIRY:
return PREF_CACHED_ACTION_HIDE_EXPIRY;
case ContentSuggestionsNotificationAction.HIDE_FRONTMOST:
return PREF_CACHED_ACTION_HIDE_FRONTMOST;
case ContentSuggestionsNotificationAction.HIDE_DISABLED:
return PREF_CACHED_ACTION_HIDE_DISABLED;
case ContentSuggestionsNotificationAction.HIDE_SHUTDOWN:
return PREF_CACHED_ACTION_HIDE_SHUTDOWN;
}
return "";
}
/**
* Records that an action was performed on a notification.
*
* Also tracks the number of consecutively-ignored notifications, resetting it on a tap or
* otherwise incrementing it.
*
* This method may be called when the native library is not loaded. If it is loaded, the metrics
* will immediately be sent to C++. If not, it will cache them for a later call to
* flushCachedMetrics().
*
* @param action The action to update the pref for.
*/
private static void recordCachedActionMetric(@ContentSuggestionsNotificationAction int action) {
String prefName = cachedMetricNameForAction(action);
assert !prefName.isEmpty();
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
int currentValue = prefs.getInt(prefName, 0);
int consecutiveIgnored = prefs.getInt(PREF_CACHED_CONSECUTIVE_IGNORED, 0);
switch (action) {
case ContentSuggestionsNotificationAction.TAP:
consecutiveIgnored = 0;
break;
case ContentSuggestionsNotificationAction.DISMISSAL:
case ContentSuggestionsNotificationAction.HIDE_DEADLINE:
case ContentSuggestionsNotificationAction.HIDE_EXPIRY:
++consecutiveIgnored;
break;
case ContentSuggestionsNotificationAction.HIDE_FRONTMOST:
case ContentSuggestionsNotificationAction.HIDE_DISABLED:
case ContentSuggestionsNotificationAction.HIDE_SHUTDOWN:
break; // no change
}
prefs.edit()
.putInt(prefName, currentValue + 1)
.putInt(PREF_CACHED_CONSECUTIVE_IGNORED, consecutiveIgnored)
.apply();
flushCachedMetrics();
}
/**
* Registers the Content Suggestions notification channel on Android O.
*
* <p>The registration state is tracked in a preference, as a boolean. If the pref is false or
* missing, the channel will be (re-)registered.
*
* <p>May be called on any Android version; before Android O, it is a no-op.
*
* <p>Returns true if the channel was registered, false if not (pre-O, or already registered).
*
* @param enabled If false, the channel is created in a disabled state.
*/
@CalledByNative
private static boolean registerChannel(boolean enabled) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false;
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
if (prefs.getBoolean(PREF_CHANNEL_CREATED, false)) return false;
ChannelsInitializer initializer = new ChannelsInitializer(
new NotificationManagerProxyImpl(ContextUtils.getApplicationContext()),
ContextUtils.getApplicationContext().getResources());
if (enabled) {
initializer.ensureInitialized(ChannelDefinitions.ChannelId.CONTENT_SUGGESTIONS);
} else {
initializer.ensureInitializedAndDisabled(
ChannelDefinitions.ChannelId.CONTENT_SUGGESTIONS);
}
prefs.edit().putBoolean(PREF_CHANNEL_CREATED, true).apply();
return true;
}
/**
* Unregisters the Content Suggestions notification channel on Android O.
*
* <p>May be called on any Android version; before Android O, it is a no-op.
*/
@CalledByNative
private static void unregisterChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
if (!prefs.getBoolean(PREF_CHANNEL_CREATED, false)) return;
NotificationManagerProxy manager =
new NotificationManagerProxyImpl(ContextUtils.getApplicationContext());
manager.deleteNotificationChannel(ChannelDefinitions.ChannelId.CONTENT_SUGGESTIONS);
prefs.edit().remove(PREF_CHANNEL_CREATED).apply();
}
/**
* Invokes nativeReceiveFlushedMetrics() with cached metrics and resets them.
*
* It may be called from either native or Java code. If the browser has not been started-up, or
* startup has not completed (as when the native component flushes metrics during creation of
* the keyed service) the flush will be deferred until startup is complete.
*/
@CalledByNative
private static void flushCachedMetrics() {
BrowserStartupController browserStartup =
BrowserStartupController.get(LibraryProcessType.PROCESS_BROWSER);
if (!browserStartup.isStartupSuccessfullyCompleted()) {
browserStartup.addStartupCompletedObserver(new StartupCallback() {
@Override
public void onSuccess() {
flushCachedMetrics();
}
@Override
public void onFailure() {}
});
return;
}
SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
int tapCount = prefs.getInt(PREF_CACHED_ACTION_TAP, 0);
int dismissalCount = prefs.getInt(PREF_CACHED_ACTION_DISMISSAL, 0);
int hideDeadlineCount = prefs.getInt(PREF_CACHED_ACTION_HIDE_DEADLINE, 0);
int hideExpiryCount = prefs.getInt(PREF_CACHED_ACTION_HIDE_EXPIRY, 0);
int hideFrontmostCount = prefs.getInt(PREF_CACHED_ACTION_HIDE_FRONTMOST, 0);
int hideDisabledCount = prefs.getInt(PREF_CACHED_ACTION_HIDE_DISABLED, 0);
int hideShutdownCount = prefs.getInt(PREF_CACHED_ACTION_HIDE_SHUTDOWN, 0);
int consecutiveIgnored = prefs.getInt(PREF_CACHED_CONSECUTIVE_IGNORED, 0);
if (tapCount > 0 || dismissalCount > 0 || hideDeadlineCount > 0 || hideExpiryCount > 0
|| hideFrontmostCount > 0 || hideDisabledCount > 0 || hideShutdownCount > 0) {
nativeReceiveFlushedMetrics(tapCount, dismissalCount, hideDeadlineCount,
hideExpiryCount, hideFrontmostCount, hideDisabledCount, hideShutdownCount,
consecutiveIgnored);
prefs.edit()
.remove(PREF_CACHED_ACTION_TAP)
.remove(PREF_CACHED_ACTION_DISMISSAL)
.remove(PREF_CACHED_ACTION_HIDE_DEADLINE)
.remove(PREF_CACHED_ACTION_HIDE_EXPIRY)
.remove(PREF_CACHED_ACTION_HIDE_FRONTMOST)
.remove(PREF_CACHED_ACTION_HIDE_DISABLED)
.remove(PREF_CACHED_ACTION_HIDE_SHUTDOWN)
.remove(PREF_CACHED_CONSECUTIVE_IGNORED)
.apply();
}
}
private static native void nativeReceiveFlushedMetrics(int tapCount, int dismissalCount,
int hideDeadlineCount, int hideExpiryCount, int hideFrontmostCount,
int hideDisabledCount, int hideShutdownCount, int consecutiveIgnored);
private static native void nativeRecordNotificationOptOut(
@ContentSuggestionsNotificationOptOut int reason);
private static native void nativeRecordNotificationAction(
@ContentSuggestionsNotificationAction int action);
}