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