Add support for notification scorers.
This CL adds an interface and classes for scoring notifications.
The NotificationManagerService initializes an array of scorers
specified as a resource. When a Notification is enqueued, the
getScore() method is called successively on the scorers, each
getting the Notification to be scored, and the score outputted
by the previous scorer. At present there is a single scorer
which prioritizes Notifications that mention the display name of
a starred contact.
To turn off the StarredContactNotificationScorer:
adb shell settings put global contact_scorer_enabled 0
Change-Id: Ic16c80952e7c85bdde292ebb3f7900efb01f2e29
diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java
index 781b94e..e70ad1c 100644
--- a/core/java/android/app/Notification.java
+++ b/core/java/android/app/Notification.java
@@ -457,6 +457,13 @@
public static final String EXTRA_PEOPLE = "android.people";
/**
+ * @hide
+ * Extra added by NotificationManagerService to indicate whether a NotificationScorer
+ * modified the Notifications's score.
+ */
+ public static final String EXTRA_SCORE_MODIFIED = "android.scoreModified";
+
+ /**
* Notification extra to specify heads up display preference.
* @hide
*/
diff --git a/core/java/com/android/internal/notification/DemoContactNotificationScorer.java b/core/java/com/android/internal/notification/DemoContactNotificationScorer.java
new file mode 100644
index 0000000..081a371
--- /dev/null
+++ b/core/java/com/android/internal/notification/DemoContactNotificationScorer.java
@@ -0,0 +1,188 @@
+/*
+* Copyright (C) 2013 The Android Open Source Project
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+package com.android.internal.notification;
+
+import android.app.Notification;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.ContactsContract;
+import android.provider.Settings;
+import android.text.SpannableString;
+import android.util.Slog;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * This NotificationScorer bumps up the priority of notifications that contain references to the
+ * display names of starred contacts. The references it picks up are spannable strings which, in
+ * their entirety, match the display name of some starred contact. The magnitude of the bump ranges
+ * from 0 to 15 (assuming NOTIFICATION_PRIORITY_MULTIPLIER = 10) depending on the initial score, and
+ * the mapping is defined by priorityBumpMap. In a production version of this scorer, a notification
+ * extra will be used to specify contact identifiers.
+ */
+
+public class DemoContactNotificationScorer implements NotificationScorer {
+ private static final String TAG = "StarredContactScoring";
+ private static final boolean DBG = true;
+
+ protected static final boolean ENABLE_CONTACT_SCORER = true;
+ private static final String SETTING_ENABLE_SCORER = "contact_scorer_enabled";
+ protected boolean mEnabled;
+
+ // see NotificationManagerService
+ private static final int NOTIFICATION_PRIORITY_MULTIPLIER = 10;
+
+ private Context mContext;
+
+ private static final List<String> RELEVANT_KEYS_LIST = Arrays.asList(
+ Notification.EXTRA_INFO_TEXT, Notification.EXTRA_TEXT, Notification.EXTRA_TEXT_LINES,
+ Notification.EXTRA_SUB_TEXT, Notification.EXTRA_TITLE
+ );
+
+ private static final String[] PROJECTION = new String[] {
+ ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME
+ };
+
+ private static final Uri CONTACTS_URI = ContactsContract.Contacts.CONTENT_URI;
+
+ private static List<String> extractSpannedStrings(CharSequence charSequence) {
+ if (charSequence == null) return Collections.emptyList();
+ if (!(charSequence instanceof SpannableString)) {
+ return Arrays.asList(charSequence.toString());
+ }
+ SpannableString spannableString = (SpannableString)charSequence;
+ // get all spans
+ Object[] ssArr = spannableString.getSpans(0, spannableString.length(), Object.class);
+ // spanned string sequences
+ ArrayList<String> sss = new ArrayList<String>();
+ for (Object spanObj : ssArr) {
+ try {
+ sss.add(spannableString.subSequence(spannableString.getSpanStart(spanObj),
+ spannableString.getSpanEnd(spanObj)).toString());
+ } catch(StringIndexOutOfBoundsException e) {
+ Slog.e(TAG, "Bad indices when extracting spanned subsequence", e);
+ }
+ }
+ return sss;
+ };
+
+ private static String getQuestionMarksInParens(int n) {
+ StringBuilder sb = new StringBuilder("(");
+ for (int i = 0; i < n; i++) {
+ if (sb.length() > 1) sb.append(',');
+ sb.append('?');
+ }
+ sb.append(")");
+ return sb.toString();
+ }
+
+ private boolean hasStarredContact(Bundle extras) {
+ if (extras == null) return false;
+ ArrayList<String> qStrings = new ArrayList<String>();
+ // build list to query against the database for display names.
+ for (String rk: RELEVANT_KEYS_LIST) {
+ if (extras.get(rk) == null) {
+ continue;
+ } else if (extras.get(rk) instanceof CharSequence) {
+ qStrings.addAll(extractSpannedStrings((CharSequence) extras.get(rk)));
+ } else if (extras.get(rk) instanceof CharSequence[]) {
+ // this is intended for Notification.EXTRA_TEXT_LINES
+ for (CharSequence line: (CharSequence[]) extras.get(rk)){
+ qStrings.addAll(extractSpannedStrings(line));
+ }
+ } else {
+ Slog.w(TAG, "Strange, the extra " + rk + " is of unexpected type.");
+ }
+ }
+ if (qStrings.isEmpty()) return false;
+ String[] qStringsArr = qStrings.toArray(new String[qStrings.size()]);
+
+ String selection = ContactsContract.Contacts.DISPLAY_NAME + " IN "
+ + getQuestionMarksInParens(qStringsArr.length) + " AND "
+ + ContactsContract.Contacts.STARRED+" ='1'";
+
+ Cursor c = null;
+ try {
+ c = mContext.getContentResolver().query(
+ CONTACTS_URI, PROJECTION, selection, qStringsArr, null);
+ if (c != null) return c.getCount() > 0;
+ } catch(Throwable t) {
+ Slog.w(TAG, "Problem getting content resolver or performing contacts query.", t);
+ } finally {
+ if (c != null) {
+ c.close();
+ }
+ }
+ return false;
+ }
+
+ private final static int clamp(int x, int low, int high) {
+ return (x < low) ? low : ((x > high) ? high : x);
+ }
+
+ private static int priorityBumpMap(int incomingScore) {
+ //assumption is that scale runs from [-2*pm, 2*pm]
+ int pm = NOTIFICATION_PRIORITY_MULTIPLIER;
+ int theScore = incomingScore;
+ // enforce input in range
+ theScore = clamp(theScore, -2 * pm, 2 * pm);
+ if (theScore != incomingScore) return incomingScore;
+ // map -20 -> -20 and -10 -> 5 (when pm = 10)
+ if (theScore <= -pm) {
+ theScore += 1.5 * (theScore + 2 * pm);
+ } else {
+ // map 0 -> 10, 10 -> 15, 20 -> 20;
+ theScore += 0.5 * (2 * pm - theScore);
+ }
+ if (DBG) Slog.v(TAG, "priorityBumpMap: score before: " + incomingScore
+ + ", score after " + theScore + ".");
+ return theScore;
+ }
+
+ @Override
+ public void initialize(Context context) {
+ if (DBG) Slog.v(TAG, "Initializing " + getClass().getSimpleName() + ".");
+ mContext = context;
+ mEnabled = ENABLE_CONTACT_SCORER && 1 == Settings.Global.getInt(
+ mContext.getContentResolver(), SETTING_ENABLE_SCORER, 1);
+ }
+
+ @Override
+ public int getScore(Notification notification, int score) {
+ if (notification == null || !mEnabled) {
+ if (DBG) Slog.w(TAG, "empty notification? scorer disabled?");
+ return score;
+ }
+ boolean hasStarredPriority = hasStarredContact(notification.extras);
+
+ if (DBG) {
+ if (hasStarredPriority) {
+ Slog.v(TAG, "Notification references starred contact. Promoted!");
+ } else {
+ Slog.v(TAG, "Notification lacks any starred contact reference. Not promoted!");
+ }
+ }
+ if (hasStarredPriority) score = priorityBumpMap(score);
+ return score;
+ }
+}
+
diff --git a/core/java/com/android/internal/notification/NotificationScorer.java b/core/java/com/android/internal/notification/NotificationScorer.java
new file mode 100644
index 0000000..863c08c
--- /dev/null
+++ b/core/java/com/android/internal/notification/NotificationScorer.java
@@ -0,0 +1,27 @@
+/*
+* Copyright (C) 2013 The Android Open Source Project
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+package com.android.internal.notification;
+
+import android.app.Notification;
+import android.content.Context;
+
+public interface NotificationScorer {
+
+ public void initialize(Context context);
+ public int getScore(Notification notification, int score);
+
+}
diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml
index da6dfd3..1a5dc37 100644
--- a/core/res/res/values/config.xml
+++ b/core/res/res/values/config.xml
@@ -1153,4 +1153,8 @@
<item>com.android.inputmethod.latin</item>
</string-array>
+ <string-array name="config_notificationScorers">
+ <item>com.android.internal.notification.DemoContactNotificationScorer</item>
+ </string-array>
+
</resources>
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index 8a12ac8..f1f826d 100755
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -1570,6 +1570,7 @@
<java-symbol type="id" name="button_once" />
<java-symbol type="id" name="button_always" />
<java-symbol type="integer" name="config_maxResolverActivityColumns" />
+ <java-symbol type="array" name="config_notificationScorers" />
<!-- From SystemUI -->
<java-symbol type="anim" name="push_down_in" />
diff --git a/services/java/com/android/server/NotificationManagerService.java b/services/java/com/android/server/NotificationManagerService.java
index c98a85a..bdf6129 100644
--- a/services/java/com/android/server/NotificationManagerService.java
+++ b/services/java/com/android/server/NotificationManagerService.java
@@ -75,6 +75,9 @@
import android.view.accessibility.AccessibilityManager;
import android.widget.Toast;
+import com.android.internal.R;
+
+import com.android.internal.notification.NotificationScorer;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
@@ -199,6 +202,8 @@
private static final String TAG_PACKAGE = "package";
private static final String ATTR_NAME = "name";
+ private final ArrayList<NotificationScorer> mScorers = new ArrayList<NotificationScorer>();
+
private class NotificationListenerInfo implements DeathRecipient {
INotificationListener listener;
ComponentName component;
@@ -707,7 +712,7 @@
intent.setComponent(name);
intent.putExtra(Intent.EXTRA_CLIENT_LABEL,
- com.android.internal.R.string.notification_listener_binding_label);
+ R.string.notification_listener_binding_label);
intent.putExtra(Intent.EXTRA_CLIENT_INTENT, PendingIntent.getActivity(
mContext, 0, new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS), 0));
@@ -1297,19 +1302,19 @@
Resources resources = mContext.getResources();
mDefaultNotificationColor = resources.getColor(
- com.android.internal.R.color.config_defaultNotificationColor);
+ R.color.config_defaultNotificationColor);
mDefaultNotificationLedOn = resources.getInteger(
- com.android.internal.R.integer.config_defaultNotificationLedOn);
+ R.integer.config_defaultNotificationLedOn);
mDefaultNotificationLedOff = resources.getInteger(
- com.android.internal.R.integer.config_defaultNotificationLedOff);
+ R.integer.config_defaultNotificationLedOff);
mDefaultVibrationPattern = getLongArray(resources,
- com.android.internal.R.array.config_defaultNotificationVibePattern,
+ R.array.config_defaultNotificationVibePattern,
VIBRATE_PATTERN_MAXLEN,
DEFAULT_VIBRATE_PATTERN);
mFallbackVibrationPattern = getLongArray(resources,
- com.android.internal.R.array.config_notificationFallbackVibePattern,
+ R.array.config_notificationFallbackVibePattern,
VIBRATE_PATTERN_MAXLEN,
DEFAULT_VIBRATE_PATTERN);
@@ -1344,6 +1349,24 @@
mSettingsObserver = new SettingsObserver(mHandler);
mSettingsObserver.observe();
+
+ // spin up NotificationScorers
+ String[] notificationScorerNames = resources.getStringArray(
+ R.array.config_notificationScorers);
+ for (String scorerName : notificationScorerNames) {
+ try {
+ Class<?> scorerClass = mContext.getClassLoader().loadClass(scorerName);
+ NotificationScorer scorer = (NotificationScorer) scorerClass.newInstance();
+ scorer.initialize(mContext);
+ mScorers.add(scorer);
+ } catch (ClassNotFoundException e) {
+ Slog.w(TAG, "Couldn't find scorer " + scorerName + ".", e);
+ } catch (InstantiationException e) {
+ Slog.w(TAG, "Couldn't instantiate scorer " + scorerName + ".", e);
+ } catch (IllegalAccessException e) {
+ Slog.w(TAG, "Problem accessing scorer " + scorerName + ".", e);
+ }
+ }
}
/**
@@ -1683,6 +1706,23 @@
// 3. Apply local rules
+ int initialScore = score;
+ if (!mScorers.isEmpty()) {
+ if (DBG) Slog.v(TAG, "Initial score is " + score + ".");
+ for (NotificationScorer scorer : mScorers) {
+ try {
+ score = scorer.getScore(notification, score);
+ } catch (Throwable t) {
+ Slog.w(TAG, "Scorer threw on .getScore.", t);
+ }
+ }
+ if (DBG) Slog.v(TAG, "Final score is " + score + ".");
+ }
+
+ // add extra to indicate score modified by NotificationScorer
+ notification.extras.putBoolean(Notification.EXTRA_SCORE_MODIFIED,
+ score != initialScore);
+
// blocked apps
if (ENABLE_BLOCKED_NOTIFICATIONS && !noteNotificationOp(pkg, callingUid)) {
if (!isSystemNotification) {