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