Merge "notification ranking infrastructure"

This commit is contained in:
Chris Wren
2014-05-12 20:48:43 +00:00
committed by Android (Google) Code Review
16 changed files with 868 additions and 535 deletions

View File

@@ -659,8 +659,8 @@ public class Notification implements Parcelable
/**
* @hide
* Extra added by NotificationManagerService to indicate whether a NotificationScorer
* modified the Notifications's score.
* Extra added by NotificationManagerService to indicate whether
* the Notifications's score has been modified.
*/
public static final String EXTRA_SCORE_MODIFIED = "android.scoreModified";

View File

@@ -17,11 +17,15 @@
package android.service.notification;
import android.service.notification.StatusBarNotification;
import android.service.notification.NotificationOrderUpdate;
/** @hide */
oneway interface INotificationListener
{
void onListenerConnected(in String[] notificationKeys);
void onNotificationPosted(in StatusBarNotification notification);
void onNotificationRemoved(in StatusBarNotification notification);
void onListenerConnected(in NotificationOrderUpdate update);
void onNotificationPosted(in StatusBarNotification notification,
in NotificationOrderUpdate update);
void onNotificationRemoved(in StatusBarNotification notification,
in NotificationOrderUpdate update);
void onNotificationOrderUpdate(in NotificationOrderUpdate update);
}

View File

@@ -22,10 +22,13 @@ import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.IBinder;
import android.os.RemoteException;
import android.os.ServiceManager;
import android.os.UserHandle;
import android.util.Log;
import java.util.Comparator;
import java.util.HashMap;
/**
* A service that receives calls from the system when new notifications are posted or removed.
* <p>To extend this class, you must declare the service in your manifest file with
@@ -46,6 +49,7 @@ public abstract class NotificationListenerService extends Service {
+ "[" + getClass().getSimpleName() + "]";
private INotificationListenerWrapper mWrapper = null;
private String[] mNotificationKeys;
private INotificationManager mNoMan;
@@ -95,6 +99,15 @@ public abstract class NotificationListenerService extends Service {
// optional
}
/**
* Implement this method to be notified when the notification order cahnges.
*
* Call {@link #getOrderedNotificationKeys()} to retrieve the new order.
*/
public void onNotificationOrderUpdate() {
// optional
}
private final INotificationManager getNotificationInterface() {
if (mNoMan == null) {
mNoMan = INotificationManager.Stub.asInterface(
@@ -202,7 +215,7 @@ public abstract class NotificationListenerService extends Service {
* Request the list of outstanding notifications (that is, those that are visible to the
* current user). Useful when you don't know what's already been posted.
*
* @return An array of active notifications.
* @return An array of active notifications, sorted in natural order.
*/
public StatusBarNotification[] getActiveNotifications() {
return getActiveNotifications(null /*all*/);
@@ -213,7 +226,8 @@ public abstract class NotificationListenerService extends Service {
* current user). Useful when you don't know what's already been posted.
*
* @param keys A specific list of notification keys, or {@code null} for all.
* @return An array of active notifications.
* @return An array of active notifications, sorted in natural order
* if {@code keys} is {@code null}.
*/
public StatusBarNotification[] getActiveNotifications(String[] keys) {
if (!isBound()) return null;
@@ -226,21 +240,15 @@ public abstract class NotificationListenerService extends Service {
}
/**
* Request the list of outstanding notification keys(that is, those that are visible to the
* current user). You can use the notification keys for subsequent retrieval via
* Request the list of notification keys in their current natural order.
* You can use the notification keys for subsequent retrieval via
* {@link #getActiveNotifications(String[]) or dismissal via
* {@link #cancelNotifications(String[]).
*
* @return An array of active notification keys.
* @return An array of active notification keys, in their natural order.
*/
public String[] getActiveNotificationKeys() {
if (!isBound()) return null;
try {
return getNotificationInterface().getActiveNotificationKeysFromListener(mWrapper);
} catch (android.os.RemoteException ex) {
Log.v(TAG, "Unable to contact notification manager", ex);
}
return null;
public String[] getOrderedNotificationKeys() {
return mNotificationKeys;
}
@Override
@@ -261,28 +269,60 @@ public abstract class NotificationListenerService extends Service {
private class INotificationListenerWrapper extends INotificationListener.Stub {
@Override
public void onNotificationPosted(StatusBarNotification sbn) {
public void onNotificationPosted(StatusBarNotification sbn,
NotificationOrderUpdate update) {
try {
NotificationListenerService.this.onNotificationPosted(sbn);
// protect subclass from concurrent modifications of (@link mNotificationKeys}.
synchronized (mWrapper) {
updateNotificationKeys(update);
NotificationListenerService.this.onNotificationPosted(sbn);
}
} catch (Throwable t) {
Log.w(TAG, "Error running onNotificationPosted", t);
Log.w(TAG, "Error running onOrderedNotificationPosted", t);
}
}
@Override
public void onNotificationRemoved(StatusBarNotification sbn) {
public void onNotificationRemoved(StatusBarNotification sbn,
NotificationOrderUpdate update) {
try {
NotificationListenerService.this.onNotificationRemoved(sbn);
// protect subclass from concurrent modifications of (@link mNotificationKeys}.
synchronized (mWrapper) {
updateNotificationKeys(update);
NotificationListenerService.this.onNotificationRemoved(sbn);
}
} catch (Throwable t) {
Log.w(TAG, "Error running onNotificationRemoved", t);
}
}
@Override
public void onListenerConnected(String[] notificationKeys) {
public void onListenerConnected(NotificationOrderUpdate update) {
try {
NotificationListenerService.this.onListenerConnected(notificationKeys);
// protect subclass from concurrent modifications of (@link mNotificationKeys}.
synchronized (mWrapper) {
updateNotificationKeys(update);
NotificationListenerService.this.onListenerConnected(mNotificationKeys);
}
} catch (Throwable t) {
Log.w(TAG, "Error running onListenerConnected", t);
}
}
@Override
public void onNotificationOrderUpdate(NotificationOrderUpdate update)
throws RemoteException {
try {
// protect subclass from concurrent modifications of (@link mNotificationKeys}.
synchronized (mWrapper) {
updateNotificationKeys(update);
NotificationListenerService.this.onNotificationOrderUpdate();
}
} catch (Throwable t) {
Log.w(TAG, "Error running onNotificationOrderUpdate", t);
}
}
}
private void updateNotificationKeys(NotificationOrderUpdate update) {
// TODO: avoid garbage by comparing the lists
mNotificationKeys = update.getOrderedKeys();
}
}

View File

@@ -0,0 +1,19 @@
/**
* Copyright (c) 2014, 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 android.service.notification;
parcelable NotificationOrderUpdate;

View File

@@ -0,0 +1,62 @@
/*
* Copyright (C) 2014 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 android.service.notification;
import android.os.Parcel;
import android.os.Parcelable;
public class NotificationOrderUpdate implements Parcelable {
// TODO replace this with an update instead of the whole array
private final String[] mKeys;
/** @hide */
public NotificationOrderUpdate(String[] keys) {
this.mKeys = keys;
}
public NotificationOrderUpdate(Parcel in) {
this.mKeys = in.readStringArray();
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel out, int flags) {
out.writeStringArray(this.mKeys);
}
public static final Parcelable.Creator<NotificationOrderUpdate> CREATOR
= new Parcelable.Creator<NotificationOrderUpdate>() {
public NotificationOrderUpdate createFromParcel(Parcel parcel) {
return new NotificationOrderUpdate(parcel);
}
public NotificationOrderUpdate[] newArray(int size) {
return new NotificationOrderUpdate[size];
}
};
/**
* @hide
* @return ordered list of keys
*/
String[] getOrderedKeys() {
return mKeys;
}
}

View File

@@ -1,188 +0,0 @@
/*
* 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 = "DemoContactNotificationScorer";
private static final boolean DBG = false;
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, 0);
}
@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;
}
}

View File

@@ -1,27 +0,0 @@
/*
* 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);
}

View File

@@ -1,227 +0,0 @@
/*
* Copyright (C) 2014 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.ContactsContract.Contacts;
import android.provider.Settings;
import android.text.TextUtils;
import android.util.LruCache;
import android.util.Slog;
/**
* This {@link NotificationScorer} attempts to validate people references.
* Also elevates the priority of real people.
*/
public class PeopleNotificationScorer implements NotificationScorer {
private static final String TAG = "PeopleNotificationScorer";
private static final boolean DBG = false;
private static final boolean ENABLE_PEOPLE_SCORER = true;
private static final String SETTING_ENABLE_PEOPLE_SCORER = "people_scorer_enabled";
private static final String[] LOOKUP_PROJECTION = { Contacts._ID };
private static final int MAX_PEOPLE = 10;
private static final int PEOPLE_CACHE_SIZE = 200;
// see NotificationManagerService
private static final int NOTIFICATION_PRIORITY_MULTIPLIER = 10;
protected boolean mEnabled;
private Context mContext;
// maps raw person handle to resolved person object
private LruCache<String, LookupResult> mPeopleCache;
private float findMaxContactScore(Bundle extras) {
if (extras == null) {
return 0f;
}
final String[] people = extras.getStringArray(Notification.EXTRA_PEOPLE);
if (people == null || people.length == 0) {
return 0f;
}
float rank = 0f;
for (int personIdx = 0; personIdx < people.length && personIdx < MAX_PEOPLE; personIdx++) {
final String handle = people[personIdx];
if (TextUtils.isEmpty(handle)) continue;
LookupResult lookupResult = mPeopleCache.get(handle);
if (lookupResult == null || lookupResult.isExpired()) {
final Uri uri = Uri.parse(handle);
if ("tel".equals(uri.getScheme())) {
if (DBG) Slog.w(TAG, "checking telephone URI: " + handle);
lookupResult = lookupPhoneContact(handle, uri.getSchemeSpecificPart());
} else if (handle.startsWith(Contacts.CONTENT_LOOKUP_URI.toString())) {
if (DBG) Slog.w(TAG, "checking lookup URI: " + handle);
lookupResult = resolveContactsUri(handle, uri);
} else {
if (DBG) Slog.w(TAG, "unsupported URI " + handle);
}
} else {
if (DBG) Slog.w(TAG, "using cached lookupResult: " + lookupResult.mId);
}
if (lookupResult != null) {
rank = Math.max(rank, lookupResult.getRank());
}
}
return rank;
}
private LookupResult lookupPhoneContact(final String handle, final String number) {
LookupResult lookupResult = null;
Cursor c = null;
try {
Uri numberUri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
Uri.encode(number));
c = mContext.getContentResolver().query(numberUri, LOOKUP_PROJECTION, null, null, null);
if (c != null && c.getCount() > 0) {
c.moveToFirst();
final int idIdx = c.getColumnIndex(Contacts._ID);
final int id = c.getInt(idIdx);
if (DBG) Slog.w(TAG, "is valid: " + id);
lookupResult = new LookupResult(id);
}
} catch(Throwable t) {
Slog.w(TAG, "Problem getting content resolver or performing contacts query.", t);
} finally {
if (c != null) {
c.close();
}
}
if (lookupResult == null) {
lookupResult = new LookupResult(LookupResult.INVALID_ID);
}
mPeopleCache.put(handle, lookupResult);
return lookupResult;
}
private LookupResult resolveContactsUri(String handle, final Uri personUri) {
LookupResult lookupResult = null;
Cursor c = null;
try {
c = mContext.getContentResolver().query(personUri, LOOKUP_PROJECTION, null, null, null);
if (c != null && c.getCount() > 0) {
c.moveToFirst();
final int idIdx = c.getColumnIndex(Contacts._ID);
final int id = c.getInt(idIdx);
if (DBG) Slog.w(TAG, "is valid: " + id);
lookupResult = new LookupResult(id);
}
} catch(Throwable t) {
Slog.w(TAG, "Problem getting content resolver or performing contacts query.", t);
} finally {
if (c != null) {
c.close();
}
}
if (lookupResult == null) {
lookupResult = new LookupResult(LookupResult.INVALID_ID);
}
mPeopleCache.put(handle, lookupResult);
return lookupResult;
}
private final static int clamp(int x, int low, int high) {
return (x < low) ? low : ((x > high) ? high : x);
}
// TODO: rework this function before shipping
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;
mPeopleCache = new LruCache<String, LookupResult>(PEOPLE_CACHE_SIZE);
mEnabled = ENABLE_PEOPLE_SCORER && 1 == Settings.Global.getInt(
mContext.getContentResolver(), SETTING_ENABLE_PEOPLE_SCORER, 0);
}
@Override
public int getScore(Notification notification, int score) {
if (notification == null || !mEnabled) {
if (DBG) Slog.w(TAG, "empty notification? scorer disabled?");
return score;
}
float contactScore = findMaxContactScore(notification.extras);
if (contactScore > 0f) {
if (DBG) Slog.v(TAG, "Notification references a real contact. Promoted!");
score = priorityBumpMap(score);
} else {
if (DBG) Slog.v(TAG, "Notification lacks any valid contact reference. Not promoted!");
}
return score;
}
private static class LookupResult {
private static final long CONTACT_REFRESH_MILLIS = 60 * 60 * 1000; // 1hr
public static final int INVALID_ID = -1;
private final long mExpireMillis;
private int mId;
public LookupResult(int id) {
mId = id;
mExpireMillis = System.currentTimeMillis() + CONTACT_REFRESH_MILLIS;
}
public boolean isExpired() {
return mExpireMillis < System.currentTimeMillis();
}
public boolean isInvalid() {
return mId == INVALID_ID || isExpired();
}
public float getRank() {
if (isInvalid()) {
return 0f;
} else {
return 1f; // TODO: finer grained score
}
}
public LookupResult setId(int id) {
mId = id;
return this;
}
}
}