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