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