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