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