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.annotation.Nullable;
20 import android.app.Notification;
21 import android.app.Person;
22 import android.content.Context;
23 import android.content.pm.PackageManager;
24 import android.database.ContentObserver;
25 import android.database.Cursor;
26 import android.net.Uri;
27 import android.os.AsyncTask;
28 import android.os.Bundle;
29 import android.os.Handler;
30 import android.os.UserHandle;
31 import android.provider.ContactsContract;
32 import android.provider.ContactsContract.Contacts;
33 import android.provider.Settings;
34 import android.text.TextUtils;
35 import android.util.ArrayMap;
36 import android.util.ArraySet;
37 import android.util.Log;
38 import android.util.LruCache;
39 import android.util.Slog;
40 
41 import java.util.ArrayList;
42 import java.util.Arrays;
43 import java.util.LinkedList;
44 import java.util.List;
45 import java.util.Map;
46 import java.util.Set;
47 import java.util.concurrent.Semaphore;
48 import java.util.concurrent.TimeUnit;
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 
initialize(Context context, NotificationUsageStats usageStats)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 
process(NotificationRecord record)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
setConfig(RankingConfig config)143     public void setConfig(RankingConfig config) {
144         // ignore: config has no relevant information yet.
145     }
146 
147     @Override
setZenHelper(ZenModeHelper helper)148     public void setZenHelper(ZenModeHelper helper) {
149 
150     }
151 
152     /**
153      * @param extras extras of the notification with EXTRA_PEOPLE populated
154      * @param timeoutMs timeout in milliseconds to wait for contacts response
155      * @param timeoutAffinity affinity to return when the timeout specified via
156      *                        <code>timeoutMs</code> is hit
157      */
getContactAffinity(UserHandle userHandle, Bundle extras, int timeoutMs, float timeoutAffinity)158     public float getContactAffinity(UserHandle userHandle, Bundle extras, int timeoutMs,
159             float timeoutAffinity) {
160         if (DEBUG) Slog.d(TAG, "checking affinity for " + userHandle);
161         if (extras == null) return NONE;
162         final String key = Long.toString(System.nanoTime());
163         final float[] affinityOut = new float[1];
164         Context context = getContextAsUser(userHandle);
165         if (context == null) {
166             return NONE;
167         }
168         final PeopleRankingReconsideration prr =
169                 validatePeople(context, key, extras, null, affinityOut);
170         float affinity = affinityOut[0];
171 
172         if (prr != null) {
173             // Perform the heavy work on a background thread so we can abort when we hit the
174             // timeout.
175             final Semaphore s = new Semaphore(0);
176             AsyncTask.THREAD_POOL_EXECUTOR.execute(new Runnable() {
177                 @Override
178                 public void run() {
179                     prr.work();
180                     s.release();
181                 }
182             });
183 
184             try {
185                 if (!s.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS)) {
186                     Slog.w(TAG, "Timeout while waiting for affinity: " + key + ". "
187                             + "Returning timeoutAffinity=" + timeoutAffinity);
188                     return timeoutAffinity;
189                 }
190             } catch (InterruptedException e) {
191                 Slog.w(TAG, "InterruptedException while waiting for affinity: " + key + ". "
192                         + "Returning affinity=" + affinity, e);
193                 return affinity;
194             }
195 
196             affinity = Math.max(prr.getContactAffinity(), affinity);
197         }
198         return affinity;
199     }
200 
getContextAsUser(UserHandle userHandle)201     private Context getContextAsUser(UserHandle userHandle) {
202         Context context = mUserToContextMap.get(userHandle.getIdentifier());
203         if (context == null) {
204             try {
205                 context = mBaseContext.createPackageContextAsUser("android", 0, userHandle);
206                 mUserToContextMap.put(userHandle.getIdentifier(), context);
207             } catch (PackageManager.NameNotFoundException e) {
208                 Log.e(TAG, "failed to create package context for lookups", e);
209             }
210         }
211         return context;
212     }
213 
validatePeople(Context context, final NotificationRecord record)214     private RankingReconsideration validatePeople(Context context,
215             final NotificationRecord record) {
216         final String key = record.getKey();
217         final Bundle extras = record.getNotification().extras;
218         final float[] affinityOut = new float[1];
219         final PeopleRankingReconsideration rr =
220                 validatePeople(context, key, extras, record.getPeopleOverride(), affinityOut);
221         final float affinity = affinityOut[0];
222         record.setContactAffinity(affinity);
223         if (rr == null) {
224             mUsageStats.registerPeopleAffinity(record, affinity > NONE, affinity == STARRED_CONTACT,
225                     true /* cached */);
226         } else {
227             rr.setRecord(record);
228         }
229         return rr;
230     }
231 
validatePeople(Context context, String key, Bundle extras, List<String> peopleOverride, float[] affinityOut)232     private PeopleRankingReconsideration validatePeople(Context context, String key, Bundle extras,
233             List<String> peopleOverride, float[] affinityOut) {
234         float affinity = NONE;
235         if (extras == null) {
236             return null;
237         }
238         final Set<String> people = new ArraySet<>(peopleOverride);
239         final String[] notificationPeople = getExtraPeople(extras);
240         if (notificationPeople != null ) {
241             people.addAll(Arrays.asList(notificationPeople));
242         }
243 
244         if (VERBOSE) Slog.i(TAG, "Validating: " + key + " for " + context.getUserId());
245         final LinkedList<String> pendingLookups = new LinkedList<String>();
246         int personIdx = 0;
247         for (String handle : people) {
248             if (TextUtils.isEmpty(handle)) continue;
249 
250             synchronized (mPeopleCache) {
251                 final String cacheKey = getCacheKey(context.getUserId(), handle);
252                 LookupResult lookupResult = mPeopleCache.get(cacheKey);
253                 if (lookupResult == null || lookupResult.isExpired()) {
254                     pendingLookups.add(handle);
255                 } else {
256                     if (DEBUG) Slog.d(TAG, "using cached lookupResult");
257                 }
258                 if (lookupResult != null) {
259                     affinity = Math.max(affinity, lookupResult.getAffinity());
260                 }
261             }
262             if (++personIdx == MAX_PEOPLE) {
263                 break;
264             }
265         }
266 
267         // record the best available data, so far:
268         affinityOut[0] = affinity;
269 
270         if (pendingLookups.isEmpty()) {
271             if (VERBOSE) Slog.i(TAG, "final affinity: " + affinity);
272             return null;
273         }
274 
275         if (DEBUG) Slog.d(TAG, "Pending: future work scheduled for: " + key);
276         return new PeopleRankingReconsideration(context, key, pendingLookups);
277     }
278 
getCacheKey(int userId, String handle)279     private String getCacheKey(int userId, String handle) {
280         return Integer.toString(userId) + ":" + handle;
281     }
282 
283     // VisibleForTesting
getExtraPeople(Bundle extras)284     public static String[] getExtraPeople(Bundle extras) {
285         String[] peopleList = getExtraPeopleForKey(extras, Notification.EXTRA_PEOPLE_LIST);
286         String[] legacyPeople = getExtraPeopleForKey(extras, Notification.EXTRA_PEOPLE);
287         return combineLists(legacyPeople, peopleList);
288     }
289 
combineLists(String[] first, String[] second)290     private static String[] combineLists(String[] first, String[] second) {
291         if (first == null) {
292             return second;
293         }
294         if (second == null) {
295             return first;
296         }
297         ArraySet<String> people = new ArraySet<>(first.length + second.length);
298         for (String person: first) {
299             people.add(person);
300         }
301         for (String person: second) {
302             people.add(person);
303         }
304         return (String[]) people.toArray();
305     }
306 
307     @Nullable
getExtraPeopleForKey(Bundle extras, String key)308     private static String[] getExtraPeopleForKey(Bundle extras, String key) {
309         Object people = extras.get(key);
310         if (people instanceof String[]) {
311             return (String[]) people;
312         }
313 
314         if (people instanceof ArrayList) {
315             ArrayList arrayList = (ArrayList) people;
316 
317             if (arrayList.isEmpty()) {
318                 return null;
319             }
320 
321             if (arrayList.get(0) instanceof String) {
322                 ArrayList<String> stringArray = (ArrayList<String>) arrayList;
323                 return stringArray.toArray(new String[stringArray.size()]);
324             }
325 
326             if (arrayList.get(0) instanceof CharSequence) {
327                 ArrayList<CharSequence> charSeqList = (ArrayList<CharSequence>) arrayList;
328                 final int N = charSeqList.size();
329                 String[] array = new String[N];
330                 for (int i = 0; i < N; i++) {
331                     array[i] = charSeqList.get(i).toString();
332                 }
333                 return array;
334             }
335 
336             if (arrayList.get(0) instanceof Person) {
337                 ArrayList<Person> list = (ArrayList<Person>) arrayList;
338                 final int N = list.size();
339                 String[] array = new String[N];
340                 for (int i = 0; i < N; i++) {
341                     array[i] = list.get(i).resolveToLegacyUri();
342                 }
343                 return array;
344             }
345 
346             return null;
347         }
348 
349         if (people instanceof String) {
350             String[] array = new String[1];
351             array[0] = (String) people;
352             return array;
353         }
354 
355         if (people instanceof char[]) {
356             String[] array = new String[1];
357             array[0] = new String((char[]) people);
358             return array;
359         }
360 
361         if (people instanceof CharSequence) {
362             String[] array = new String[1];
363             array[0] = ((CharSequence) people).toString();
364             return array;
365         }
366 
367         if (people instanceof CharSequence[]) {
368             CharSequence[] charSeqArray = (CharSequence[]) people;
369             final int N = charSeqArray.length;
370             String[] array = new String[N];
371             for (int i = 0; i < N; i++) {
372                 array[i] = charSeqArray[i].toString();
373             }
374             return array;
375         }
376 
377         return null;
378     }
379 
resolvePhoneContact(Context context, final String number)380     private LookupResult resolvePhoneContact(Context context, final String number) {
381         Uri phoneUri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
382                 Uri.encode(number));
383         return searchContacts(context, phoneUri);
384     }
385 
resolveEmailContact(Context context, final String email)386     private LookupResult resolveEmailContact(Context context, final String email) {
387         Uri numberUri = Uri.withAppendedPath(
388                 ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI,
389                 Uri.encode(email));
390         return searchContacts(context, numberUri);
391     }
392 
searchContacts(Context context, Uri lookupUri)393     private LookupResult searchContacts(Context context, Uri lookupUri) {
394         LookupResult lookupResult = new LookupResult();
395         Cursor c = null;
396         try {
397             c = context.getContentResolver().query(lookupUri, LOOKUP_PROJECTION, null, null, null);
398             if (c == null) {
399                 Slog.w(TAG, "Null cursor from contacts query.");
400                 return lookupResult;
401             }
402             while (c.moveToNext()) {
403                 lookupResult.mergeContact(c);
404             }
405         } catch (Throwable t) {
406             Slog.w(TAG, "Problem getting content resolver or performing contacts query.", t);
407         } finally {
408             if (c != null) {
409                 c.close();
410             }
411         }
412         return lookupResult;
413     }
414 
415     private static class LookupResult {
416         private static final long CONTACT_REFRESH_MILLIS = 60 * 60 * 1000;  // 1hr
417 
418         private final long mExpireMillis;
419         private float mAffinity = NONE;
420 
LookupResult()421         public LookupResult() {
422             mExpireMillis = System.currentTimeMillis() + CONTACT_REFRESH_MILLIS;
423         }
424 
mergeContact(Cursor cursor)425         public void mergeContact(Cursor cursor) {
426             mAffinity = Math.max(mAffinity, VALID_CONTACT);
427 
428             // Contact ID
429             int id;
430             final int idIdx = cursor.getColumnIndex(Contacts._ID);
431             if (idIdx >= 0) {
432                 id = cursor.getInt(idIdx);
433                 if (DEBUG) Slog.d(TAG, "contact _ID is: " + id);
434             } else {
435                 id = -1;
436                 Slog.i(TAG, "invalid cursor: no _ID");
437             }
438 
439             // Starred
440             final int starIdx = cursor.getColumnIndex(Contacts.STARRED);
441             if (starIdx >= 0) {
442                 boolean isStarred = cursor.getInt(starIdx) != 0;
443                 if (isStarred) {
444                     mAffinity = Math.max(mAffinity, STARRED_CONTACT);
445                 }
446                 if (DEBUG) Slog.d(TAG, "contact STARRED is: " + isStarred);
447             } else {
448                 if (DEBUG) Slog.d(TAG, "invalid cursor: no STARRED");
449             }
450         }
451 
isExpired()452         private boolean isExpired() {
453             return mExpireMillis < System.currentTimeMillis();
454         }
455 
isInvalid()456         private boolean isInvalid() {
457             return mAffinity == NONE || isExpired();
458         }
459 
getAffinity()460         public float getAffinity() {
461             if (isInvalid()) {
462                 return NONE;
463             }
464             return mAffinity;
465         }
466     }
467 
468     private class PeopleRankingReconsideration extends RankingReconsideration {
469         private final LinkedList<String> mPendingLookups;
470         private final Context mContext;
471 
472         // Amount of time to wait for a result from the contacts db before rechecking affinity.
473         private static final long LOOKUP_TIME = 1000;
474         private float mContactAffinity = NONE;
475         private NotificationRecord mRecord;
476 
PeopleRankingReconsideration(Context context, String key, LinkedList<String> pendingLookups)477         private PeopleRankingReconsideration(Context context, String key,
478                 LinkedList<String> pendingLookups) {
479             super(key, LOOKUP_TIME);
480             mContext = context;
481             mPendingLookups = pendingLookups;
482         }
483 
484         @Override
work()485         public void work() {
486             if (VERBOSE) Slog.i(TAG, "Executing: validation for: " + mKey);
487             long timeStartMs = System.currentTimeMillis();
488             for (final String handle: mPendingLookups) {
489                 LookupResult lookupResult = null;
490                 final Uri uri = Uri.parse(handle);
491                 if ("tel".equals(uri.getScheme())) {
492                     if (DEBUG) Slog.d(TAG, "checking telephone URI: " + handle);
493                     lookupResult = resolvePhoneContact(mContext, uri.getSchemeSpecificPart());
494                 } else if ("mailto".equals(uri.getScheme())) {
495                     if (DEBUG) Slog.d(TAG, "checking mailto URI: " + handle);
496                     lookupResult = resolveEmailContact(mContext, uri.getSchemeSpecificPart());
497                 } else if (handle.startsWith(Contacts.CONTENT_LOOKUP_URI.toString())) {
498                     if (DEBUG) Slog.d(TAG, "checking lookup URI: " + handle);
499                     lookupResult = searchContacts(mContext, uri);
500                 } else {
501                     lookupResult = new LookupResult();  // invalid person for the cache
502                     if (!"name".equals(uri.getScheme())) {
503                         Slog.w(TAG, "unsupported URI " + handle);
504                     }
505                 }
506                 if (lookupResult != null) {
507                     synchronized (mPeopleCache) {
508                         final String cacheKey = getCacheKey(mContext.getUserId(), handle);
509                         mPeopleCache.put(cacheKey, lookupResult);
510                     }
511                     if (DEBUG) {
512                         Slog.d(TAG, "lookup contactAffinity is " + lookupResult.getAffinity());
513                     }
514                     mContactAffinity = Math.max(mContactAffinity, lookupResult.getAffinity());
515                 } else {
516                     if (DEBUG) Slog.d(TAG, "lookupResult is null");
517                 }
518             }
519             if (DEBUG) {
520                 Slog.d(TAG, "Validation finished in " + (System.currentTimeMillis() - timeStartMs) +
521                         "ms");
522             }
523 
524             if (mRecord != null) {
525                 mUsageStats.registerPeopleAffinity(mRecord, mContactAffinity > NONE,
526                         mContactAffinity == STARRED_CONTACT, false /* cached */);
527             }
528         }
529 
530         @Override
applyChangesLocked(NotificationRecord operand)531         public void applyChangesLocked(NotificationRecord operand) {
532             float affinityBound = operand.getContactAffinity();
533             operand.setContactAffinity(Math.max(mContactAffinity, affinityBound));
534             if (VERBOSE) Slog.i(TAG, "final affinity: " + operand.getContactAffinity());
535         }
536 
getContactAffinity()537         public float getContactAffinity() {
538             return mContactAffinity;
539         }
540 
setRecord(NotificationRecord record)541         public void setRecord(NotificationRecord record) {
542             mRecord = record;
543         }
544     }
545 }
546 
547