/*
|
* 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.server.notification;
|
|
import android.annotation.Nullable;
|
import android.app.Notification;
|
import android.app.Person;
|
import android.content.Context;
|
import android.content.pm.PackageManager;
|
import android.database.ContentObserver;
|
import android.database.Cursor;
|
import android.net.Uri;
|
import android.os.AsyncTask;
|
import android.os.Bundle;
|
import android.os.Handler;
|
import android.os.UserHandle;
|
import android.provider.ContactsContract;
|
import android.provider.ContactsContract.Contacts;
|
import android.provider.Settings;
|
import android.text.TextUtils;
|
import android.util.ArrayMap;
|
import android.util.ArraySet;
|
import android.util.Log;
|
import android.util.LruCache;
|
import android.util.Slog;
|
|
import java.util.ArrayList;
|
import java.util.Arrays;
|
import java.util.LinkedList;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Set;
|
import java.util.concurrent.Semaphore;
|
import java.util.concurrent.TimeUnit;
|
|
/**
|
* This {@link NotificationSignalExtractor} attempts to validate
|
* people references. Also elevates the priority of real people.
|
*
|
* {@hide}
|
*/
|
public class ValidateNotificationPeople implements NotificationSignalExtractor {
|
// Using a shorter log tag since setprop has a limit of 32chars on variable name.
|
private static final String TAG = "ValidateNoPeople";
|
private static final boolean VERBOSE = Log.isLoggable(TAG, Log.VERBOSE);;
|
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
|
|
private static final boolean ENABLE_PEOPLE_VALIDATOR = true;
|
private static final String SETTING_ENABLE_PEOPLE_VALIDATOR =
|
"validate_notification_people_enabled";
|
private static final String[] LOOKUP_PROJECTION = { Contacts._ID, Contacts.STARRED };
|
private static final int MAX_PEOPLE = 10;
|
private static final int PEOPLE_CACHE_SIZE = 200;
|
|
/** Indicates that the notification does not reference any valid contacts. */
|
static final float NONE = 0f;
|
|
/**
|
* Affinity will be equal to or greater than this value on notifications
|
* that reference a valid contact.
|
*/
|
static final float VALID_CONTACT = 0.5f;
|
|
/**
|
* Affinity will be equal to or greater than this value on notifications
|
* that reference a starred contact.
|
*/
|
static final float STARRED_CONTACT = 1f;
|
|
protected boolean mEnabled;
|
private Context mBaseContext;
|
|
// maps raw person handle to resolved person object
|
private LruCache<String, LookupResult> mPeopleCache;
|
private Map<Integer, Context> mUserToContextMap;
|
private Handler mHandler;
|
private ContentObserver mObserver;
|
private int mEvictionCount;
|
private NotificationUsageStats mUsageStats;
|
|
public void initialize(Context context, NotificationUsageStats usageStats) {
|
if (DEBUG) Slog.d(TAG, "Initializing " + getClass().getSimpleName() + ".");
|
mUserToContextMap = new ArrayMap<>();
|
mBaseContext = context;
|
mUsageStats = usageStats;
|
mPeopleCache = new LruCache<String, LookupResult>(PEOPLE_CACHE_SIZE);
|
mEnabled = ENABLE_PEOPLE_VALIDATOR && 1 == Settings.Global.getInt(
|
mBaseContext.getContentResolver(), SETTING_ENABLE_PEOPLE_VALIDATOR, 1);
|
if (mEnabled) {
|
mHandler = new Handler();
|
mObserver = new ContentObserver(mHandler) {
|
@Override
|
public void onChange(boolean selfChange, Uri uri, int userId) {
|
super.onChange(selfChange, uri, userId);
|
if (DEBUG || mEvictionCount % 100 == 0) {
|
if (VERBOSE) Slog.i(TAG, "mEvictionCount: " + mEvictionCount);
|
}
|
mPeopleCache.evictAll();
|
mEvictionCount++;
|
}
|
};
|
mBaseContext.getContentResolver().registerContentObserver(Contacts.CONTENT_URI, true,
|
mObserver, UserHandle.USER_ALL);
|
}
|
}
|
|
public RankingReconsideration process(NotificationRecord record) {
|
if (!mEnabled) {
|
if (VERBOSE) Slog.i(TAG, "disabled");
|
return null;
|
}
|
if (record == null || record.getNotification() == null) {
|
if (VERBOSE) Slog.i(TAG, "skipping empty notification");
|
return null;
|
}
|
if (record.getUserId() == UserHandle.USER_ALL) {
|
if (VERBOSE) Slog.i(TAG, "skipping global notification");
|
return null;
|
}
|
Context context = getContextAsUser(record.getUser());
|
if (context == null) {
|
if (VERBOSE) Slog.i(TAG, "skipping notification that lacks a context");
|
return null;
|
}
|
return validatePeople(context, record);
|
}
|
|
@Override
|
public void setConfig(RankingConfig config) {
|
// ignore: config has no relevant information yet.
|
}
|
|
@Override
|
public void setZenHelper(ZenModeHelper helper) {
|
|
}
|
|
/**
|
* @param extras extras of the notification with EXTRA_PEOPLE populated
|
* @param timeoutMs timeout in milliseconds to wait for contacts response
|
* @param timeoutAffinity affinity to return when the timeout specified via
|
* <code>timeoutMs</code> is hit
|
*/
|
public float getContactAffinity(UserHandle userHandle, Bundle extras, int timeoutMs,
|
float timeoutAffinity) {
|
if (DEBUG) Slog.d(TAG, "checking affinity for " + userHandle);
|
if (extras == null) return NONE;
|
final String key = Long.toString(System.nanoTime());
|
final float[] affinityOut = new float[1];
|
Context context = getContextAsUser(userHandle);
|
if (context == null) {
|
return NONE;
|
}
|
final PeopleRankingReconsideration prr =
|
validatePeople(context, key, extras, null, affinityOut);
|
float affinity = affinityOut[0];
|
|
if (prr != null) {
|
// Perform the heavy work on a background thread so we can abort when we hit the
|
// timeout.
|
final Semaphore s = new Semaphore(0);
|
AsyncTask.THREAD_POOL_EXECUTOR.execute(new Runnable() {
|
@Override
|
public void run() {
|
prr.work();
|
s.release();
|
}
|
});
|
|
try {
|
if (!s.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS)) {
|
Slog.w(TAG, "Timeout while waiting for affinity: " + key + ". "
|
+ "Returning timeoutAffinity=" + timeoutAffinity);
|
return timeoutAffinity;
|
}
|
} catch (InterruptedException e) {
|
Slog.w(TAG, "InterruptedException while waiting for affinity: " + key + ". "
|
+ "Returning affinity=" + affinity, e);
|
return affinity;
|
}
|
|
affinity = Math.max(prr.getContactAffinity(), affinity);
|
}
|
return affinity;
|
}
|
|
private Context getContextAsUser(UserHandle userHandle) {
|
Context context = mUserToContextMap.get(userHandle.getIdentifier());
|
if (context == null) {
|
try {
|
context = mBaseContext.createPackageContextAsUser("android", 0, userHandle);
|
mUserToContextMap.put(userHandle.getIdentifier(), context);
|
} catch (PackageManager.NameNotFoundException e) {
|
Log.e(TAG, "failed to create package context for lookups", e);
|
}
|
}
|
return context;
|
}
|
|
private RankingReconsideration validatePeople(Context context,
|
final NotificationRecord record) {
|
final String key = record.getKey();
|
final Bundle extras = record.getNotification().extras;
|
final float[] affinityOut = new float[1];
|
final PeopleRankingReconsideration rr =
|
validatePeople(context, key, extras, record.getPeopleOverride(), affinityOut);
|
final float affinity = affinityOut[0];
|
record.setContactAffinity(affinity);
|
if (rr == null) {
|
mUsageStats.registerPeopleAffinity(record, affinity > NONE, affinity == STARRED_CONTACT,
|
true /* cached */);
|
} else {
|
rr.setRecord(record);
|
}
|
return rr;
|
}
|
|
private PeopleRankingReconsideration validatePeople(Context context, String key, Bundle extras,
|
List<String> peopleOverride, float[] affinityOut) {
|
float affinity = NONE;
|
if (extras == null) {
|
return null;
|
}
|
final Set<String> people = new ArraySet<>(peopleOverride);
|
final String[] notificationPeople = getExtraPeople(extras);
|
if (notificationPeople != null ) {
|
people.addAll(Arrays.asList(notificationPeople));
|
}
|
|
if (VERBOSE) Slog.i(TAG, "Validating: " + key + " for " + context.getUserId());
|
final LinkedList<String> pendingLookups = new LinkedList<String>();
|
int personIdx = 0;
|
for (String handle : people) {
|
if (TextUtils.isEmpty(handle)) continue;
|
|
synchronized (mPeopleCache) {
|
final String cacheKey = getCacheKey(context.getUserId(), handle);
|
LookupResult lookupResult = mPeopleCache.get(cacheKey);
|
if (lookupResult == null || lookupResult.isExpired()) {
|
pendingLookups.add(handle);
|
} else {
|
if (DEBUG) Slog.d(TAG, "using cached lookupResult");
|
}
|
if (lookupResult != null) {
|
affinity = Math.max(affinity, lookupResult.getAffinity());
|
}
|
}
|
if (++personIdx == MAX_PEOPLE) {
|
break;
|
}
|
}
|
|
// record the best available data, so far:
|
affinityOut[0] = affinity;
|
|
if (pendingLookups.isEmpty()) {
|
if (VERBOSE) Slog.i(TAG, "final affinity: " + affinity);
|
return null;
|
}
|
|
if (DEBUG) Slog.d(TAG, "Pending: future work scheduled for: " + key);
|
return new PeopleRankingReconsideration(context, key, pendingLookups);
|
}
|
|
private String getCacheKey(int userId, String handle) {
|
return Integer.toString(userId) + ":" + handle;
|
}
|
|
// VisibleForTesting
|
public static String[] getExtraPeople(Bundle extras) {
|
String[] peopleList = getExtraPeopleForKey(extras, Notification.EXTRA_PEOPLE_LIST);
|
String[] legacyPeople = getExtraPeopleForKey(extras, Notification.EXTRA_PEOPLE);
|
return combineLists(legacyPeople, peopleList);
|
}
|
|
private static String[] combineLists(String[] first, String[] second) {
|
if (first == null) {
|
return second;
|
}
|
if (second == null) {
|
return first;
|
}
|
ArraySet<String> people = new ArraySet<>(first.length + second.length);
|
for (String person: first) {
|
people.add(person);
|
}
|
for (String person: second) {
|
people.add(person);
|
}
|
return (String[]) people.toArray();
|
}
|
|
@Nullable
|
private static String[] getExtraPeopleForKey(Bundle extras, String key) {
|
Object people = extras.get(key);
|
if (people instanceof String[]) {
|
return (String[]) people;
|
}
|
|
if (people instanceof ArrayList) {
|
ArrayList arrayList = (ArrayList) people;
|
|
if (arrayList.isEmpty()) {
|
return null;
|
}
|
|
if (arrayList.get(0) instanceof String) {
|
ArrayList<String> stringArray = (ArrayList<String>) arrayList;
|
return stringArray.toArray(new String[stringArray.size()]);
|
}
|
|
if (arrayList.get(0) instanceof CharSequence) {
|
ArrayList<CharSequence> charSeqList = (ArrayList<CharSequence>) arrayList;
|
final int N = charSeqList.size();
|
String[] array = new String[N];
|
for (int i = 0; i < N; i++) {
|
array[i] = charSeqList.get(i).toString();
|
}
|
return array;
|
}
|
|
if (arrayList.get(0) instanceof Person) {
|
ArrayList<Person> list = (ArrayList<Person>) arrayList;
|
final int N = list.size();
|
String[] array = new String[N];
|
for (int i = 0; i < N; i++) {
|
array[i] = list.get(i).resolveToLegacyUri();
|
}
|
return array;
|
}
|
|
return null;
|
}
|
|
if (people instanceof String) {
|
String[] array = new String[1];
|
array[0] = (String) people;
|
return array;
|
}
|
|
if (people instanceof char[]) {
|
String[] array = new String[1];
|
array[0] = new String((char[]) people);
|
return array;
|
}
|
|
if (people instanceof CharSequence) {
|
String[] array = new String[1];
|
array[0] = ((CharSequence) people).toString();
|
return array;
|
}
|
|
if (people instanceof CharSequence[]) {
|
CharSequence[] charSeqArray = (CharSequence[]) people;
|
final int N = charSeqArray.length;
|
String[] array = new String[N];
|
for (int i = 0; i < N; i++) {
|
array[i] = charSeqArray[i].toString();
|
}
|
return array;
|
}
|
|
return null;
|
}
|
|
private LookupResult resolvePhoneContact(Context context, final String number) {
|
Uri phoneUri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
|
Uri.encode(number));
|
return searchContacts(context, phoneUri);
|
}
|
|
private LookupResult resolveEmailContact(Context context, final String email) {
|
Uri numberUri = Uri.withAppendedPath(
|
ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI,
|
Uri.encode(email));
|
return searchContacts(context, numberUri);
|
}
|
|
private LookupResult searchContacts(Context context, Uri lookupUri) {
|
LookupResult lookupResult = new LookupResult();
|
Cursor c = null;
|
try {
|
c = context.getContentResolver().query(lookupUri, LOOKUP_PROJECTION, null, null, null);
|
if (c == null) {
|
Slog.w(TAG, "Null cursor from contacts query.");
|
return lookupResult;
|
}
|
while (c.moveToNext()) {
|
lookupResult.mergeContact(c);
|
}
|
} catch (Throwable t) {
|
Slog.w(TAG, "Problem getting content resolver or performing contacts query.", t);
|
} finally {
|
if (c != null) {
|
c.close();
|
}
|
}
|
return lookupResult;
|
}
|
|
private static class LookupResult {
|
private static final long CONTACT_REFRESH_MILLIS = 60 * 60 * 1000; // 1hr
|
|
private final long mExpireMillis;
|
private float mAffinity = NONE;
|
|
public LookupResult() {
|
mExpireMillis = System.currentTimeMillis() + CONTACT_REFRESH_MILLIS;
|
}
|
|
public void mergeContact(Cursor cursor) {
|
mAffinity = Math.max(mAffinity, VALID_CONTACT);
|
|
// Contact ID
|
int id;
|
final int idIdx = cursor.getColumnIndex(Contacts._ID);
|
if (idIdx >= 0) {
|
id = cursor.getInt(idIdx);
|
if (DEBUG) Slog.d(TAG, "contact _ID is: " + id);
|
} else {
|
id = -1;
|
Slog.i(TAG, "invalid cursor: no _ID");
|
}
|
|
// Starred
|
final int starIdx = cursor.getColumnIndex(Contacts.STARRED);
|
if (starIdx >= 0) {
|
boolean isStarred = cursor.getInt(starIdx) != 0;
|
if (isStarred) {
|
mAffinity = Math.max(mAffinity, STARRED_CONTACT);
|
}
|
if (DEBUG) Slog.d(TAG, "contact STARRED is: " + isStarred);
|
} else {
|
if (DEBUG) Slog.d(TAG, "invalid cursor: no STARRED");
|
}
|
}
|
|
private boolean isExpired() {
|
return mExpireMillis < System.currentTimeMillis();
|
}
|
|
private boolean isInvalid() {
|
return mAffinity == NONE || isExpired();
|
}
|
|
public float getAffinity() {
|
if (isInvalid()) {
|
return NONE;
|
}
|
return mAffinity;
|
}
|
}
|
|
private class PeopleRankingReconsideration extends RankingReconsideration {
|
private final LinkedList<String> mPendingLookups;
|
private final Context mContext;
|
|
// Amount of time to wait for a result from the contacts db before rechecking affinity.
|
private static final long LOOKUP_TIME = 1000;
|
private float mContactAffinity = NONE;
|
private NotificationRecord mRecord;
|
|
private PeopleRankingReconsideration(Context context, String key,
|
LinkedList<String> pendingLookups) {
|
super(key, LOOKUP_TIME);
|
mContext = context;
|
mPendingLookups = pendingLookups;
|
}
|
|
@Override
|
public void work() {
|
if (VERBOSE) Slog.i(TAG, "Executing: validation for: " + mKey);
|
long timeStartMs = System.currentTimeMillis();
|
for (final String handle: mPendingLookups) {
|
LookupResult lookupResult = null;
|
final Uri uri = Uri.parse(handle);
|
if ("tel".equals(uri.getScheme())) {
|
if (DEBUG) Slog.d(TAG, "checking telephone URI: " + handle);
|
lookupResult = resolvePhoneContact(mContext, uri.getSchemeSpecificPart());
|
} else if ("mailto".equals(uri.getScheme())) {
|
if (DEBUG) Slog.d(TAG, "checking mailto URI: " + handle);
|
lookupResult = resolveEmailContact(mContext, uri.getSchemeSpecificPart());
|
} else if (handle.startsWith(Contacts.CONTENT_LOOKUP_URI.toString())) {
|
if (DEBUG) Slog.d(TAG, "checking lookup URI: " + handle);
|
lookupResult = searchContacts(mContext, uri);
|
} else {
|
lookupResult = new LookupResult(); // invalid person for the cache
|
if (!"name".equals(uri.getScheme())) {
|
Slog.w(TAG, "unsupported URI " + handle);
|
}
|
}
|
if (lookupResult != null) {
|
synchronized (mPeopleCache) {
|
final String cacheKey = getCacheKey(mContext.getUserId(), handle);
|
mPeopleCache.put(cacheKey, lookupResult);
|
}
|
if (DEBUG) {
|
Slog.d(TAG, "lookup contactAffinity is " + lookupResult.getAffinity());
|
}
|
mContactAffinity = Math.max(mContactAffinity, lookupResult.getAffinity());
|
} else {
|
if (DEBUG) Slog.d(TAG, "lookupResult is null");
|
}
|
}
|
if (DEBUG) {
|
Slog.d(TAG, "Validation finished in " + (System.currentTimeMillis() - timeStartMs) +
|
"ms");
|
}
|
|
if (mRecord != null) {
|
mUsageStats.registerPeopleAffinity(mRecord, mContactAffinity > NONE,
|
mContactAffinity == STARRED_CONTACT, false /* cached */);
|
}
|
}
|
|
@Override
|
public void applyChangesLocked(NotificationRecord operand) {
|
float affinityBound = operand.getContactAffinity();
|
operand.setContactAffinity(Math.max(mContactAffinity, affinityBound));
|
if (VERBOSE) Slog.i(TAG, "final affinity: " + operand.getContactAffinity());
|
}
|
|
public float getContactAffinity() {
|
return mContactAffinity;
|
}
|
|
public void setRecord(NotificationRecord record) {
|
mRecord = record;
|
}
|
}
|
}
|