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