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