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