ValidateNotificationPeople.java revision 12aeda802ed91a49977a22166319ce74a3352e30
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: " + lookupResult.mId); 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 && c.getCount() > 0) { 340 c.moveToFirst(); 341 lookupResult.readContact(c); 342 } 343 } catch(Throwable t) { 344 Slog.w(TAG, "Problem getting content resolver or performing contacts query.", t); 345 } finally { 346 if (c != null) { 347 c.close(); 348 } 349 } 350 return lookupResult; 351 } 352 353 private static class LookupResult { 354 private static final long CONTACT_REFRESH_MILLIS = 60 * 60 * 1000; // 1hr 355 public static final int INVALID_ID = -1; 356 357 private final long mExpireMillis; 358 private int mId; 359 private boolean mStarred; 360 361 public LookupResult() { 362 mId = INVALID_ID; 363 mStarred = false; 364 mExpireMillis = System.currentTimeMillis() + CONTACT_REFRESH_MILLIS; 365 } 366 367 public void readContact(Cursor cursor) { 368 final int idIdx = cursor.getColumnIndex(Contacts._ID); 369 if (idIdx >= 0) { 370 mId = cursor.getInt(idIdx); 371 if (DEBUG) Slog.d(TAG, "contact _ID is: " + mId); 372 } else { 373 if (DEBUG) Slog.d(TAG, "invalid cursor: no _ID"); 374 } 375 final int starIdx = cursor.getColumnIndex(Contacts.STARRED); 376 if (starIdx >= 0) { 377 mStarred = cursor.getInt(starIdx) != 0; 378 if (DEBUG) Slog.d(TAG, "contact STARRED is: " + mStarred); 379 } else { 380 if (DEBUG) Slog.d(TAG, "invalid cursor: no STARRED"); 381 } 382 } 383 384 public boolean isExpired() { 385 return mExpireMillis < System.currentTimeMillis(); 386 } 387 388 public boolean isInvalid() { 389 return mId == INVALID_ID || isExpired(); 390 } 391 392 public float getAffinity() { 393 if (isInvalid()) { 394 return NONE; 395 } else if (mStarred) { 396 return STARRED_CONTACT; 397 } else { 398 return VALID_CONTACT; 399 } 400 } 401 402 public LookupResult setStarred(boolean starred) { 403 mStarred = starred; 404 return this; 405 } 406 407 public LookupResult setId(int id) { 408 mId = id; 409 return this; 410 } 411 } 412 413 private class PeopleRankingReconsideration extends RankingReconsideration { 414 private final LinkedList<String> mPendingLookups; 415 private final Context mContext; 416 417 private float mContactAffinity = NONE; 418 419 private PeopleRankingReconsideration(Context context, String key, LinkedList<String> pendingLookups) { 420 super(key); 421 mContext = context; 422 mPendingLookups = pendingLookups; 423 } 424 425 @Override 426 public void work() { 427 if (INFO) Slog.i(TAG, "Executing: validation for: " + mKey); 428 long timeStartMs = System.currentTimeMillis(); 429 for (final String handle: mPendingLookups) { 430 LookupResult lookupResult = null; 431 final Uri uri = Uri.parse(handle); 432 if ("tel".equals(uri.getScheme())) { 433 if (DEBUG) Slog.d(TAG, "checking telephone URI: " + handle); 434 lookupResult = resolvePhoneContact(mContext, uri.getSchemeSpecificPart()); 435 } else if ("mailto".equals(uri.getScheme())) { 436 if (DEBUG) Slog.d(TAG, "checking mailto URI: " + handle); 437 lookupResult = resolveEmailContact(mContext, uri.getSchemeSpecificPart()); 438 } else if (handle.startsWith(Contacts.CONTENT_LOOKUP_URI.toString())) { 439 if (DEBUG) Slog.d(TAG, "checking lookup URI: " + handle); 440 lookupResult = searchContacts(mContext, uri); 441 } else { 442 lookupResult = new LookupResult(); // invalid person for the cache 443 Slog.w(TAG, "unsupported URI " + handle); 444 } 445 if (lookupResult != null) { 446 synchronized (mPeopleCache) { 447 final String cacheKey = getCacheKey(mContext.getUserId(), handle); 448 mPeopleCache.put(cacheKey, lookupResult); 449 } 450 mContactAffinity = Math.max(mContactAffinity, lookupResult.getAffinity()); 451 } 452 } 453 if (DEBUG) { 454 Slog.d(TAG, "Validation finished in " + (System.currentTimeMillis() - timeStartMs) + 455 "ms"); 456 } 457 } 458 459 @Override 460 public void applyChangesLocked(NotificationRecord operand) { 461 float affinityBound = operand.getContactAffinity(); 462 operand.setContactAffinity(Math.max(mContactAffinity, affinityBound)); 463 if (INFO) Slog.i(TAG, "final affinity: " + operand.getContactAffinity()); 464 } 465 466 public float getContactAffinity() { 467 return mContactAffinity; 468 } 469 } 470} 471 472