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
17package com.android.server.notification;
18
19import android.annotation.Nullable;
20import android.app.Notification;
21import android.app.Person;
22import android.content.Context;
23import android.content.pm.PackageManager;
24import android.database.ContentObserver;
25import android.database.Cursor;
26import android.net.Uri;
27import android.os.AsyncTask;
28import android.os.Bundle;
29import android.os.Handler;
30import android.os.UserHandle;
31import android.provider.ContactsContract;
32import android.provider.ContactsContract.Contacts;
33import android.provider.Settings;
34import android.text.TextUtils;
35import android.util.ArrayMap;
36import android.util.ArraySet;
37import android.util.Log;
38import android.util.LruCache;
39import android.util.Slog;
40
41import java.util.ArrayList;
42import java.util.Arrays;
43import java.util.LinkedList;
44import java.util.List;
45import java.util.Map;
46import java.util.Set;
47import java.util.concurrent.Semaphore;
48import 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 */
56public 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    @Override
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     */
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
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
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
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
279    private String getCacheKey(int userId, String handle) {
280        return Integer.toString(userId) + ":" + handle;
281    }
282
283    // VisibleForTesting
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
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
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
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
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
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
421        public LookupResult() {
422            mExpireMillis = System.currentTimeMillis() + CONTACT_REFRESH_MILLIS;
423        }
424
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
452        private boolean isExpired() {
453            return mExpireMillis < System.currentTimeMillis();
454        }
455
456        private boolean isInvalid() {
457            return mAffinity == NONE || isExpired();
458        }
459
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
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
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
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
537        public float getContactAffinity() {
538            return mContactAffinity;
539        }
540
541        public void setRecord(NotificationRecord record) {
542            mRecord = record;
543        }
544    }
545}
546
547