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