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