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 
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     /**
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      */
getContactAffinity(UserHandle userHandle, Bundle extras, int timeoutMs, float timeoutAffinity)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 
getContextAsUser(UserHandle userHandle)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 
validatePeople(Context context, final NotificationRecord record)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 
validatePeople(Context context, String key, Bundle extras, List<String> peopleOverride, float[] affinityOut)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 
getCacheKey(int userId, String handle)275     private String getCacheKey(int userId, String handle) {
276         return Integer.toString(userId) + ":" + handle;
277     }
278 
279     // VisibleForTesting
getExtraPeople(Bundle extras)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 
resolvePhoneContact(Context context, final String number)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 
resolveEmailContact(Context context, final String email)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 
searchContacts(Context context, Uri lookupUri)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 
LookupResult()383         public LookupResult() {
384             mExpireMillis = System.currentTimeMillis() + CONTACT_REFRESH_MILLIS;
385         }
386 
mergeContact(Cursor cursor)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 
isExpired()414         private boolean isExpired() {
415             return mExpireMillis < System.currentTimeMillis();
416         }
417 
isInvalid()418         private boolean isInvalid() {
419             return mAffinity == NONE || isExpired();
420         }
421 
getAffinity()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 
PeopleRankingReconsideration(Context context, String key, LinkedList<String> pendingLookups)437         private PeopleRankingReconsideration(Context context, String key, LinkedList<String> pendingLookups) {
438             super(key);
439             mContext = context;
440             mPendingLookups = pendingLookups;
441         }
442 
443         @Override
work()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
applyChangesLocked(NotificationRecord operand)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 
getContactAffinity()493         public float getContactAffinity() {
494             return mContactAffinity;
495         }
496 
setRecord(NotificationRecord record)497         public void setRecord(NotificationRecord record) {
498             mRecord = record;
499         }
500     }
501 }
502 
503