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