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.ArraySet; 35 import android.util.Log; 36 import android.util.LruCache; 37 import android.util.Slog; 38 39 import java.util.ArrayList; 40 import java.util.Arrays; 41 import java.util.LinkedList; 42 import java.util.List; 43 import java.util.Map; 44 import java.util.Set; 45 import java.util.concurrent.Semaphore; 46 import java.util.concurrent.TimeUnit; 47 48 import android.os.SystemClock; 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 /** 148 * @param extras extras of the notification with EXTRA_PEOPLE populated 149 * @param timeoutMs timeout in milliseconds to wait for contacts response 150 * @param timeoutAffinity affinity to return when the timeout specified via 151 * <code>timeoutMs</code> is hit 152 */ getContactAffinity(UserHandle userHandle, Bundle extras, int timeoutMs, float timeoutAffinity)153 public float getContactAffinity(UserHandle userHandle, Bundle extras, int timeoutMs, 154 float timeoutAffinity) { 155 if (DEBUG) Slog.d(TAG, "checking affinity for " + userHandle); 156 if (extras == null) return NONE; 157 final String key = Long.toString(System.nanoTime()); 158 final float[] affinityOut = new float[1]; 159 Context context = getContextAsUser(userHandle); 160 if (context == null) { 161 return NONE; 162 } 163 final PeopleRankingReconsideration prr = 164 validatePeople(context, key, extras, null, affinityOut); 165 float affinity = affinityOut[0]; 166 167 if (prr != null) { 168 // Perform the heavy work on a background thread so we can abort when we hit the 169 // timeout. 170 final Semaphore s = new Semaphore(0); 171 AsyncTask.THREAD_POOL_EXECUTOR.execute(new Runnable() { 172 @Override 173 public void run() { 174 prr.work(); 175 s.release(); 176 } 177 }); 178 179 try { 180 if (!s.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS)) { 181 Slog.w(TAG, "Timeout while waiting for affinity: " + key + ". " 182 + "Returning timeoutAffinity=" + timeoutAffinity); 183 return timeoutAffinity; 184 } 185 } catch (InterruptedException e) { 186 Slog.w(TAG, "InterruptedException while waiting for affinity: " + key + ". " 187 + "Returning affinity=" + affinity, e); 188 return affinity; 189 } 190 191 affinity = Math.max(prr.getContactAffinity(), affinity); 192 } 193 return affinity; 194 } 195 getContextAsUser(UserHandle userHandle)196 private Context getContextAsUser(UserHandle userHandle) { 197 Context context = mUserToContextMap.get(userHandle.getIdentifier()); 198 if (context == null) { 199 try { 200 context = mBaseContext.createPackageContextAsUser("android", 0, userHandle); 201 mUserToContextMap.put(userHandle.getIdentifier(), context); 202 } catch (PackageManager.NameNotFoundException e) { 203 Log.e(TAG, "failed to create package context for lookups", e); 204 } 205 } 206 return context; 207 } 208 validatePeople(Context context, final NotificationRecord record)209 private RankingReconsideration validatePeople(Context context, 210 final NotificationRecord record) { 211 final String key = record.getKey(); 212 final Bundle extras = record.getNotification().extras; 213 final float[] affinityOut = new float[1]; 214 final PeopleRankingReconsideration rr = 215 validatePeople(context, key, extras, record.getPeopleOverride(), affinityOut); 216 final float affinity = affinityOut[0]; 217 record.setContactAffinity(affinity); 218 if (rr == null) { 219 mUsageStats.registerPeopleAffinity(record, affinity > NONE, affinity == STARRED_CONTACT, 220 true /* cached */); 221 } else { 222 rr.setRecord(record); 223 } 224 return rr; 225 } 226 validatePeople(Context context, String key, Bundle extras, List<String> peopleOverride, float[] affinityOut)227 private PeopleRankingReconsideration validatePeople(Context context, String key, Bundle extras, 228 List<String> peopleOverride, float[] affinityOut) { 229 long start = SystemClock.elapsedRealtime(); 230 float affinity = NONE; 231 if (extras == null) { 232 return null; 233 } 234 final Set<String> people = new ArraySet<>(peopleOverride); 235 final String[] notificationPeople = getExtraPeople(extras); 236 if (notificationPeople != null ) { 237 people.addAll(Arrays.asList(getExtraPeople(extras))); 238 } 239 240 if (VERBOSE) Slog.i(TAG, "Validating: " + key + " for " + context.getUserId()); 241 final LinkedList<String> pendingLookups = new LinkedList<String>(); 242 int personIdx = 0; 243 for (String handle : people) { 244 if (TextUtils.isEmpty(handle)) continue; 245 246 synchronized (mPeopleCache) { 247 final String cacheKey = getCacheKey(context.getUserId(), handle); 248 LookupResult lookupResult = mPeopleCache.get(cacheKey); 249 if (lookupResult == null || lookupResult.isExpired()) { 250 pendingLookups.add(handle); 251 } else { 252 if (DEBUG) Slog.d(TAG, "using cached lookupResult"); 253 } 254 if (lookupResult != null) { 255 affinity = Math.max(affinity, lookupResult.getAffinity()); 256 } 257 } 258 if (++personIdx == MAX_PEOPLE) { 259 break; 260 } 261 } 262 263 // record the best available data, so far: 264 affinityOut[0] = affinity; 265 266 if (pendingLookups.isEmpty()) { 267 if (VERBOSE) Slog.i(TAG, "final affinity: " + affinity); 268 return null; 269 } 270 271 if (DEBUG) Slog.d(TAG, "Pending: future work scheduled for: " + key); 272 return new PeopleRankingReconsideration(context, key, pendingLookups); 273 } 274 getCacheKey(int userId, String handle)275 private String getCacheKey(int userId, String handle) { 276 return Integer.toString(userId) + ":" + handle; 277 } 278 279 // VisibleForTesting getExtraPeople(Bundle extras)280 public static String[] getExtraPeople(Bundle extras) { 281 Object people = extras.get(Notification.EXTRA_PEOPLE); 282 if (people instanceof String[]) { 283 return (String[]) people; 284 } 285 286 if (people instanceof ArrayList) { 287 ArrayList arrayList = (ArrayList) people; 288 289 if (arrayList.isEmpty()) { 290 return null; 291 } 292 293 if (arrayList.get(0) instanceof String) { 294 ArrayList<String> stringArray = (ArrayList<String>) arrayList; 295 return stringArray.toArray(new String[stringArray.size()]); 296 } 297 298 if (arrayList.get(0) instanceof CharSequence) { 299 ArrayList<CharSequence> charSeqList = (ArrayList<CharSequence>) arrayList; 300 final int N = charSeqList.size(); 301 String[] array = new String[N]; 302 for (int i = 0; i < N; i++) { 303 array[i] = charSeqList.get(i).toString(); 304 } 305 return array; 306 } 307 308 return null; 309 } 310 311 if (people instanceof String) { 312 String[] array = new String[1]; 313 array[0] = (String) people; 314 return array; 315 } 316 317 if (people instanceof char[]) { 318 String[] array = new String[1]; 319 array[0] = new String((char[]) people); 320 return array; 321 } 322 323 if (people instanceof CharSequence) { 324 String[] array = new String[1]; 325 array[0] = ((CharSequence) people).toString(); 326 return array; 327 } 328 329 if (people instanceof CharSequence[]) { 330 CharSequence[] charSeqArray = (CharSequence[]) people; 331 final int N = charSeqArray.length; 332 String[] array = new String[N]; 333 for (int i = 0; i < N; i++) { 334 array[i] = charSeqArray[i].toString(); 335 } 336 return array; 337 } 338 339 return null; 340 } 341 resolvePhoneContact(Context context, final String number)342 private LookupResult resolvePhoneContact(Context context, final String number) { 343 Uri phoneUri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, 344 Uri.encode(number)); 345 return searchContacts(context, phoneUri); 346 } 347 resolveEmailContact(Context context, final String email)348 private LookupResult resolveEmailContact(Context context, final String email) { 349 Uri numberUri = Uri.withAppendedPath( 350 ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI, 351 Uri.encode(email)); 352 return searchContacts(context, numberUri); 353 } 354 searchContacts(Context context, Uri lookupUri)355 private LookupResult searchContacts(Context context, Uri lookupUri) { 356 LookupResult lookupResult = new LookupResult(); 357 Cursor c = null; 358 try { 359 c = context.getContentResolver().query(lookupUri, LOOKUP_PROJECTION, null, null, null); 360 if (c == null) { 361 Slog.w(TAG, "Null cursor from contacts query."); 362 return lookupResult; 363 } 364 while (c.moveToNext()) { 365 lookupResult.mergeContact(c); 366 } 367 } catch (Throwable t) { 368 Slog.w(TAG, "Problem getting content resolver or performing contacts query.", t); 369 } finally { 370 if (c != null) { 371 c.close(); 372 } 373 } 374 return lookupResult; 375 } 376 377 private static class LookupResult { 378 private static final long CONTACT_REFRESH_MILLIS = 60 * 60 * 1000; // 1hr 379 380 private final long mExpireMillis; 381 private float mAffinity = NONE; 382 LookupResult()383 public LookupResult() { 384 mExpireMillis = System.currentTimeMillis() + CONTACT_REFRESH_MILLIS; 385 } 386 mergeContact(Cursor cursor)387 public void mergeContact(Cursor cursor) { 388 mAffinity = Math.max(mAffinity, VALID_CONTACT); 389 390 // Contact ID 391 int id; 392 final int idIdx = cursor.getColumnIndex(Contacts._ID); 393 if (idIdx >= 0) { 394 id = cursor.getInt(idIdx); 395 if (DEBUG) Slog.d(TAG, "contact _ID is: " + id); 396 } else { 397 id = -1; 398 Slog.i(TAG, "invalid cursor: no _ID"); 399 } 400 401 // Starred 402 final int starIdx = cursor.getColumnIndex(Contacts.STARRED); 403 if (starIdx >= 0) { 404 boolean isStarred = cursor.getInt(starIdx) != 0; 405 if (isStarred) { 406 mAffinity = Math.max(mAffinity, STARRED_CONTACT); 407 } 408 if (DEBUG) Slog.d(TAG, "contact STARRED is: " + isStarred); 409 } else { 410 if (DEBUG) Slog.d(TAG, "invalid cursor: no STARRED"); 411 } 412 } 413 isExpired()414 private boolean isExpired() { 415 return mExpireMillis < System.currentTimeMillis(); 416 } 417 isInvalid()418 private boolean isInvalid() { 419 return mAffinity == NONE || isExpired(); 420 } 421 getAffinity()422 public float getAffinity() { 423 if (isInvalid()) { 424 return NONE; 425 } 426 return mAffinity; 427 } 428 } 429 430 private class PeopleRankingReconsideration extends RankingReconsideration { 431 private final LinkedList<String> mPendingLookups; 432 private final Context mContext; 433 434 private float mContactAffinity = NONE; 435 private NotificationRecord mRecord; 436 PeopleRankingReconsideration(Context context, String key, LinkedList<String> pendingLookups)437 private PeopleRankingReconsideration(Context context, String key, LinkedList<String> pendingLookups) { 438 super(key); 439 mContext = context; 440 mPendingLookups = pendingLookups; 441 } 442 443 @Override work()444 public void work() { 445 long start = SystemClock.elapsedRealtime(); 446 if (VERBOSE) Slog.i(TAG, "Executing: validation for: " + mKey); 447 long timeStartMs = System.currentTimeMillis(); 448 for (final String handle: mPendingLookups) { 449 LookupResult lookupResult = null; 450 final Uri uri = Uri.parse(handle); 451 if ("tel".equals(uri.getScheme())) { 452 if (DEBUG) Slog.d(TAG, "checking telephone URI: " + handle); 453 lookupResult = resolvePhoneContact(mContext, uri.getSchemeSpecificPart()); 454 } else if ("mailto".equals(uri.getScheme())) { 455 if (DEBUG) Slog.d(TAG, "checking mailto URI: " + handle); 456 lookupResult = resolveEmailContact(mContext, uri.getSchemeSpecificPart()); 457 } else if (handle.startsWith(Contacts.CONTENT_LOOKUP_URI.toString())) { 458 if (DEBUG) Slog.d(TAG, "checking lookup URI: " + handle); 459 lookupResult = searchContacts(mContext, uri); 460 } else { 461 lookupResult = new LookupResult(); // invalid person for the cache 462 Slog.w(TAG, "unsupported URI " + handle); 463 } 464 if (lookupResult != null) { 465 synchronized (mPeopleCache) { 466 final String cacheKey = getCacheKey(mContext.getUserId(), handle); 467 mPeopleCache.put(cacheKey, lookupResult); 468 } 469 if (DEBUG) Slog.d(TAG, "lookup contactAffinity is " + lookupResult.getAffinity()); 470 mContactAffinity = Math.max(mContactAffinity, lookupResult.getAffinity()); 471 } else { 472 if (DEBUG) Slog.d(TAG, "lookupResult is null"); 473 } 474 } 475 if (DEBUG) { 476 Slog.d(TAG, "Validation finished in " + (System.currentTimeMillis() - timeStartMs) + 477 "ms"); 478 } 479 480 if (mRecord != null) { 481 mUsageStats.registerPeopleAffinity(mRecord, mContactAffinity > NONE, 482 mContactAffinity == STARRED_CONTACT, false /* cached */); 483 } 484 } 485 486 @Override applyChangesLocked(NotificationRecord operand)487 public void applyChangesLocked(NotificationRecord operand) { 488 float affinityBound = operand.getContactAffinity(); 489 operand.setContactAffinity(Math.max(mContactAffinity, affinityBound)); 490 if (VERBOSE) Slog.i(TAG, "final affinity: " + operand.getContactAffinity()); 491 } 492 getContactAffinity()493 public float getContactAffinity() { 494 return mContactAffinity; 495 } 496 setRecord(NotificationRecord record)497 public void setRecord(NotificationRecord record) { 498 mRecord = record; 499 } 500 } 501 } 502 503