Home | History | Annotate | Download | only in notification
      1 /*
      2 * Copyright (C) 2014 The Android Open Source Project
      3 *
      4 * Licensed under the Apache License, Version 2.0 (the "License");
      5 * you may not use this file except in compliance with the License.
      6 * You may obtain a copy of the License at
      7 *
      8 *      http://www.apache.org/licenses/LICENSE-2.0
      9 *
     10 * Unless required by applicable law or agreed to in writing, software
     11 * distributed under the License is distributed on an "AS IS" BASIS,
     12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13 * See the License for the specific language governing permissions and
     14 * limitations under the License.
     15 */
     16 
     17 package com.android.server.notification;
     18 
     19 import android.app.Notification;
     20 import android.content.Context;
     21 import android.content.pm.PackageManager;
     22 import android.database.ContentObserver;
     23 import android.database.Cursor;
     24 import android.net.Uri;
     25 import android.os.AsyncTask;
     26 import android.os.Bundle;
     27 import android.os.Handler;
     28 import android.os.UserHandle;
     29 import android.provider.ContactsContract;
     30 import android.provider.ContactsContract.Contacts;
     31 import android.provider.Settings;
     32 import android.text.TextUtils;
     33 import android.util.ArrayMap;
     34 import android.util.Log;
     35 import android.util.LruCache;
     36 import android.util.Slog;
     37 
     38 import java.util.ArrayList;
     39 import java.util.LinkedList;
     40 import java.util.Map;
     41 import java.util.concurrent.Semaphore;
     42 import java.util.concurrent.TimeUnit;
     43 
     44 /**
     45  * This {@link NotificationSignalExtractor} attempts to validate
     46  * people references. Also elevates the priority of real people.
     47  *
     48  * {@hide}
     49  */
     50 public class ValidateNotificationPeople implements NotificationSignalExtractor {
     51     // Using a shorter log tag since setprop has a limit of 32chars on variable name.
     52     private static final String TAG = "ValidateNoPeople";
     53     private static final boolean VERBOSE = Log.isLoggable(TAG, Log.VERBOSE);;
     54     private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
     55 
     56     private static final boolean ENABLE_PEOPLE_VALIDATOR = true;
     57     private static final String SETTING_ENABLE_PEOPLE_VALIDATOR =
     58             "validate_notification_people_enabled";
     59     private static final String[] LOOKUP_PROJECTION = { Contacts._ID, Contacts.STARRED };
     60     private static final int MAX_PEOPLE = 10;
     61     private static final int PEOPLE_CACHE_SIZE = 200;
     62 
     63     /** Indicates that the notification does not reference any valid contacts. */
     64     static final float NONE = 0f;
     65 
     66     /**
     67      * Affinity will be equal to or greater than this value on notifications
     68      * that reference a valid contact.
     69      */
     70     static final float VALID_CONTACT = 0.5f;
     71 
     72     /**
     73      * Affinity will be equal to or greater than this value on notifications
     74      * that reference a starred contact.
     75      */
     76     static final float STARRED_CONTACT = 1f;
     77 
     78     protected boolean mEnabled;
     79     private Context mBaseContext;
     80 
     81     // maps raw person handle to resolved person object
     82     private LruCache<String, LookupResult> mPeopleCache;
     83     private Map<Integer, Context> mUserToContextMap;
     84     private Handler mHandler;
     85     private ContentObserver mObserver;
     86     private int mEvictionCount;
     87     private NotificationUsageStats mUsageStats;
     88 
     89     public void initialize(Context context, NotificationUsageStats usageStats) {
     90         if (DEBUG) Slog.d(TAG, "Initializing  " + getClass().getSimpleName() + ".");
     91         mUserToContextMap = new ArrayMap<>();
     92         mBaseContext = context;
     93         mUsageStats = usageStats;
     94         mPeopleCache = new LruCache<String, LookupResult>(PEOPLE_CACHE_SIZE);
     95         mEnabled = ENABLE_PEOPLE_VALIDATOR && 1 == Settings.Global.getInt(
     96                 mBaseContext.getContentResolver(), SETTING_ENABLE_PEOPLE_VALIDATOR, 1);
     97         if (mEnabled) {
     98             mHandler = new Handler();
     99             mObserver = new ContentObserver(mHandler) {
    100                 @Override
    101                 public void onChange(boolean selfChange, Uri uri, int userId) {
    102                     super.onChange(selfChange, uri, userId);
    103                     if (DEBUG || mEvictionCount % 100 == 0) {
    104                         if (VERBOSE) Slog.i(TAG, "mEvictionCount: " + mEvictionCount);
    105                     }
    106                     mPeopleCache.evictAll();
    107                     mEvictionCount++;
    108                 }
    109             };
    110             mBaseContext.getContentResolver().registerContentObserver(Contacts.CONTENT_URI, true,
    111                     mObserver, UserHandle.USER_ALL);
    112         }
    113     }
    114 
    115     public RankingReconsideration process(NotificationRecord record) {
    116         if (!mEnabled) {
    117             if (VERBOSE) Slog.i(TAG, "disabled");
    118             return null;
    119         }
    120         if (record == null || record.getNotification() == null) {
    121             if (VERBOSE) Slog.i(TAG, "skipping empty notification");
    122             return null;
    123         }
    124         if (record.getUserId() == UserHandle.USER_ALL) {
    125             if (VERBOSE) Slog.i(TAG, "skipping global notification");
    126             return null;
    127         }
    128         Context context = getContextAsUser(record.getUser());
    129         if (context == null) {
    130             if (VERBOSE) Slog.i(TAG, "skipping notification that lacks a context");
    131             return null;
    132         }
    133         return validatePeople(context, record);
    134     }
    135 
    136     @Override
    137     public void setConfig(RankingConfig config) {
    138         // ignore: config has no relevant information yet.
    139     }
    140 
    141     /**
    142      * @param extras extras of the notification with EXTRA_PEOPLE populated
    143      * @param timeoutMs timeout in milliseconds to wait for contacts response
    144      * @param timeoutAffinity affinity to return when the timeout specified via
    145      *                        <code>timeoutMs</code> is hit
    146      */
    147     public float getContactAffinity(UserHandle userHandle, Bundle extras, int timeoutMs,
    148             float timeoutAffinity) {
    149         if (DEBUG) Slog.d(TAG, "checking affinity for " + userHandle);
    150         if (extras == null) return NONE;
    151         final String key = Long.toString(System.nanoTime());
    152         final float[] affinityOut = new float[1];
    153         Context context = getContextAsUser(userHandle);
    154         if (context == null) {
    155             return NONE;
    156         }
    157         final PeopleRankingReconsideration prr = validatePeople(context, key, extras, affinityOut);
    158         float affinity = affinityOut[0];
    159 
    160         if (prr != null) {
    161             // Perform the heavy work on a background thread so we can abort when we hit the
    162             // timeout.
    163             final Semaphore s = new Semaphore(0);
    164             AsyncTask.THREAD_POOL_EXECUTOR.execute(new Runnable() {
    165                 @Override
    166                 public void run() {
    167                     prr.work();
    168                     s.release();
    169                 }
    170             });
    171 
    172             try {
    173                 if (!s.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS)) {
    174                     Slog.w(TAG, "Timeout while waiting for affinity: " + key + ". "
    175                             + "Returning timeoutAffinity=" + timeoutAffinity);
    176                     return timeoutAffinity;
    177                 }
    178             } catch (InterruptedException e) {
    179                 Slog.w(TAG, "InterruptedException while waiting for affinity: " + key + ". "
    180                         + "Returning affinity=" + affinity, e);
    181                 return affinity;
    182             }
    183 
    184             affinity = Math.max(prr.getContactAffinity(), affinity);
    185         }
    186         return affinity;
    187     }
    188 
    189     private Context getContextAsUser(UserHandle userHandle) {
    190         Context context = mUserToContextMap.get(userHandle.getIdentifier());
    191         if (context == null) {
    192             try {
    193                 context = mBaseContext.createPackageContextAsUser("android", 0, userHandle);
    194                 mUserToContextMap.put(userHandle.getIdentifier(), context);
    195             } catch (PackageManager.NameNotFoundException e) {
    196                 Log.e(TAG, "failed to create package context for lookups", e);
    197             }
    198         }
    199         return context;
    200     }
    201 
    202     private RankingReconsideration validatePeople(Context context,
    203             final NotificationRecord record) {
    204         final String key = record.getKey();
    205         final Bundle extras = record.getNotification().extras;
    206         final float[] affinityOut = new float[1];
    207         final PeopleRankingReconsideration rr = validatePeople(context, key, extras, affinityOut);
    208         final float affinity = affinityOut[0];
    209         record.setContactAffinity(affinity);
    210         if (rr == null) {
    211             mUsageStats.registerPeopleAffinity(record, affinity > NONE, affinity == STARRED_CONTACT,
    212                     true /* cached */);
    213         } else {
    214             rr.setRecord(record);
    215         }
    216         return rr;
    217     }
    218 
    219     private PeopleRankingReconsideration validatePeople(Context context, String key, Bundle extras,
    220             float[] affinityOut) {
    221         float affinity = NONE;
    222         if (extras == null) {
    223             return null;
    224         }
    225 
    226         final String[] people = getExtraPeople(extras);
    227         if (people == null || people.length == 0) {
    228             return null;
    229         }
    230 
    231         if (VERBOSE) Slog.i(TAG, "Validating: " + key + " for " + context.getUserId());
    232         final LinkedList<String> pendingLookups = new LinkedList<String>();
    233         for (int personIdx = 0; personIdx < people.length && personIdx < MAX_PEOPLE; personIdx++) {
    234             final String handle = people[personIdx];
    235             if (TextUtils.isEmpty(handle)) continue;
    236 
    237             synchronized (mPeopleCache) {
    238                 final String cacheKey = getCacheKey(context.getUserId(), handle);
    239                 LookupResult lookupResult = mPeopleCache.get(cacheKey);
    240                 if (lookupResult == null || lookupResult.isExpired()) {
    241                     pendingLookups.add(handle);
    242                 } else {
    243                     if (DEBUG) Slog.d(TAG, "using cached lookupResult");
    244                 }
    245                 if (lookupResult != null) {
    246                     affinity = Math.max(affinity, lookupResult.getAffinity());
    247                 }
    248             }
    249         }
    250 
    251         // record the best available data, so far:
    252         affinityOut[0] = affinity;
    253 
    254         if (pendingLookups.isEmpty()) {
    255             if (VERBOSE) Slog.i(TAG, "final affinity: " + affinity);
    256             return null;
    257         }
    258 
    259         if (DEBUG) Slog.d(TAG, "Pending: future work scheduled for: " + key);
    260         return new PeopleRankingReconsideration(context, key, pendingLookups);
    261     }
    262 
    263     private String getCacheKey(int userId, String handle) {
    264         return Integer.toString(userId) + ":" + handle;
    265     }
    266 
    267     // VisibleForTesting
    268     public static String[] getExtraPeople(Bundle extras) {
    269         Object people = extras.get(Notification.EXTRA_PEOPLE);
    270         if (people instanceof String[]) {
    271             return (String[]) people;
    272         }
    273 
    274         if (people instanceof ArrayList) {
    275             ArrayList arrayList = (ArrayList) people;
    276 
    277             if (arrayList.isEmpty()) {
    278                 return null;
    279             }
    280 
    281             if (arrayList.get(0) instanceof String) {
    282                 ArrayList<String> stringArray = (ArrayList<String>) arrayList;
    283                 return stringArray.toArray(new String[stringArray.size()]);
    284             }
    285 
    286             if (arrayList.get(0) instanceof CharSequence) {
    287                 ArrayList<CharSequence> charSeqList = (ArrayList<CharSequence>) arrayList;
    288                 final int N = charSeqList.size();
    289                 String[] array = new String[N];
    290                 for (int i = 0; i < N; i++) {
    291                     array[i] = charSeqList.get(i).toString();
    292                 }
    293                 return array;
    294             }
    295 
    296             return null;
    297         }
    298 
    299         if (people instanceof String) {
    300             String[] array = new String[1];
    301             array[0] = (String) people;
    302             return array;
    303         }
    304 
    305         if (people instanceof char[]) {
    306             String[] array = new String[1];
    307             array[0] = new String((char[]) people);
    308             return array;
    309         }
    310 
    311         if (people instanceof CharSequence) {
    312             String[] array = new String[1];
    313             array[0] = ((CharSequence) people).toString();
    314             return array;
    315         }
    316 
    317         if (people instanceof CharSequence[]) {
    318             CharSequence[] charSeqArray = (CharSequence[]) people;
    319             final int N = charSeqArray.length;
    320             String[] array = new String[N];
    321             for (int i = 0; i < N; i++) {
    322                 array[i] = charSeqArray[i].toString();
    323             }
    324             return array;
    325         }
    326 
    327         return null;
    328     }
    329 
    330     private LookupResult resolvePhoneContact(Context context, final String number) {
    331         Uri phoneUri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
    332                 Uri.encode(number));
    333         return searchContacts(context, phoneUri);
    334     }
    335 
    336     private LookupResult resolveEmailContact(Context context, final String email) {
    337         Uri numberUri = Uri.withAppendedPath(
    338                 ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI,
    339                 Uri.encode(email));
    340         return searchContacts(context, numberUri);
    341     }
    342 
    343     private LookupResult searchContacts(Context context, Uri lookupUri) {
    344         LookupResult lookupResult = new LookupResult();
    345         Cursor c = null;
    346         try {
    347             c = context.getContentResolver().query(lookupUri, LOOKUP_PROJECTION, null, null, null);
    348             if (c == null) {
    349                 Slog.w(TAG, "Null cursor from contacts query.");
    350                 return lookupResult;
    351             }
    352             while (c.moveToNext()) {
    353                 lookupResult.mergeContact(c);
    354             }
    355         } catch (Throwable t) {
    356             Slog.w(TAG, "Problem getting content resolver or performing contacts query.", t);
    357         } finally {
    358             if (c != null) {
    359                 c.close();
    360             }
    361         }
    362         return lookupResult;
    363     }
    364 
    365     private static class LookupResult {
    366         private static final long CONTACT_REFRESH_MILLIS = 60 * 60 * 1000;  // 1hr
    367 
    368         private final long mExpireMillis;
    369         private float mAffinity = NONE;
    370 
    371         public LookupResult() {
    372             mExpireMillis = System.currentTimeMillis() + CONTACT_REFRESH_MILLIS;
    373         }
    374 
    375         public void mergeContact(Cursor cursor) {
    376             mAffinity = Math.max(mAffinity, VALID_CONTACT);
    377 
    378             // Contact ID
    379             int id;
    380             final int idIdx = cursor.getColumnIndex(Contacts._ID);
    381             if (idIdx >= 0) {
    382                 id = cursor.getInt(idIdx);
    383                 if (DEBUG) Slog.d(TAG, "contact _ID is: " + id);
    384             } else {
    385                 id = -1;
    386                 Slog.i(TAG, "invalid cursor: no _ID");
    387             }
    388 
    389             // Starred
    390             final int starIdx = cursor.getColumnIndex(Contacts.STARRED);
    391             if (starIdx >= 0) {
    392                 boolean isStarred = cursor.getInt(starIdx) != 0;
    393                 if (isStarred) {
    394                     mAffinity = Math.max(mAffinity, STARRED_CONTACT);
    395                 }
    396                 if (DEBUG) Slog.d(TAG, "contact STARRED is: " + isStarred);
    397             } else {
    398                 if (DEBUG) Slog.d(TAG, "invalid cursor: no STARRED");
    399             }
    400         }
    401 
    402         private boolean isExpired() {
    403             return mExpireMillis < System.currentTimeMillis();
    404         }
    405 
    406         private boolean isInvalid() {
    407             return mAffinity == NONE || isExpired();
    408         }
    409 
    410         public float getAffinity() {
    411             if (isInvalid()) {
    412                 return NONE;
    413             }
    414             return mAffinity;
    415         }
    416     }
    417 
    418     private class PeopleRankingReconsideration extends RankingReconsideration {
    419         private final LinkedList<String> mPendingLookups;
    420         private final Context mContext;
    421 
    422         private float mContactAffinity = NONE;
    423         private NotificationRecord mRecord;
    424 
    425         private PeopleRankingReconsideration(Context context, String key, LinkedList<String> pendingLookups) {
    426             super(key);
    427             mContext = context;
    428             mPendingLookups = pendingLookups;
    429         }
    430 
    431         @Override
    432         public void work() {
    433             if (VERBOSE) Slog.i(TAG, "Executing: validation for: " + mKey);
    434             long timeStartMs = System.currentTimeMillis();
    435             for (final String handle: mPendingLookups) {
    436                 LookupResult lookupResult = null;
    437                 final Uri uri = Uri.parse(handle);
    438                 if ("tel".equals(uri.getScheme())) {
    439                     if (DEBUG) Slog.d(TAG, "checking telephone URI: " + handle);
    440                     lookupResult = resolvePhoneContact(mContext, uri.getSchemeSpecificPart());
    441                 } else if ("mailto".equals(uri.getScheme())) {
    442                     if (DEBUG) Slog.d(TAG, "checking mailto URI: " + handle);
    443                     lookupResult = resolveEmailContact(mContext, uri.getSchemeSpecificPart());
    444                 } else if (handle.startsWith(Contacts.CONTENT_LOOKUP_URI.toString())) {
    445                     if (DEBUG) Slog.d(TAG, "checking lookup URI: " + handle);
    446                     lookupResult = searchContacts(mContext, uri);
    447                 } else {
    448                     lookupResult = new LookupResult();  // invalid person for the cache
    449                     Slog.w(TAG, "unsupported URI " + handle);
    450                 }
    451                 if (lookupResult != null) {
    452                     synchronized (mPeopleCache) {
    453                         final String cacheKey = getCacheKey(mContext.getUserId(), handle);
    454                         mPeopleCache.put(cacheKey, lookupResult);
    455                     }
    456                     if (DEBUG) Slog.d(TAG, "lookup contactAffinity is " + lookupResult.getAffinity());
    457                     mContactAffinity = Math.max(mContactAffinity, lookupResult.getAffinity());
    458                 } else {
    459                     if (DEBUG) Slog.d(TAG, "lookupResult is null");
    460                 }
    461             }
    462             if (DEBUG) {
    463                 Slog.d(TAG, "Validation finished in " + (System.currentTimeMillis() - timeStartMs) +
    464                         "ms");
    465             }
    466 
    467             if (mRecord != null) {
    468                 mUsageStats.registerPeopleAffinity(mRecord, mContactAffinity > NONE,
    469                         mContactAffinity == STARRED_CONTACT, false /* cached */);
    470             }
    471         }
    472 
    473         @Override
    474         public void applyChangesLocked(NotificationRecord operand) {
    475             float affinityBound = operand.getContactAffinity();
    476             operand.setContactAffinity(Math.max(mContactAffinity, affinityBound));
    477             if (VERBOSE) Slog.i(TAG, "final affinity: " + operand.getContactAffinity());
    478         }
    479 
    480         public float getContactAffinity() {
    481             return mContactAffinity;
    482         }
    483 
    484         public void setRecord(NotificationRecord record) {
    485             mRecord = record;
    486         }
    487     }
    488 }
    489 
    490