| /* | |
| * 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.app.Notification; | |
| 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; | |
| import android.os.SystemClock; | |
| /** | |
| * 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. | |
| } | |
| /** | |
| * @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) { | |
| long start = SystemClock.elapsedRealtime(); | |
| 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(getExtraPeople(extras))); | |
| } | |
| 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) { | |
| Object people = extras.get(Notification.EXTRA_PEOPLE); | |
| 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; | |
| } | |
| 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; | |
| private float mContactAffinity = NONE; | |
| private NotificationRecord mRecord; | |
| private PeopleRankingReconsideration(Context context, String key, LinkedList<String> pendingLookups) { | |
| super(key); | |
| mContext = context; | |
| mPendingLookups = pendingLookups; | |
| } | |
| @Override | |
| public void work() { | |
| long start = SystemClock.elapsedRealtime(); | |
| 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 | |
| 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; | |
| } | |
| } | |
| } | |