1 /* 2 * Copyright (C) 2016 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 package com.android.server.notification; 17 18 import android.annotation.NonNull; 19 import android.app.AlarmManager; 20 import android.app.PendingIntent; 21 import android.content.BroadcastReceiver; 22 import android.content.Context; 23 import android.content.Intent; 24 import android.content.IntentFilter; 25 import android.net.Uri; 26 import android.os.Binder; 27 import android.os.UserHandle; 28 import android.service.notification.StatusBarNotification; 29 import android.util.ArrayMap; 30 import android.util.IntArray; 31 import android.util.Log; 32 import android.util.Slog; 33 34 import com.android.internal.annotations.VisibleForTesting; 35 import com.android.internal.logging.MetricsLogger; 36 import com.android.internal.logging.nano.MetricsProto; 37 import com.android.modules.utils.TypedXmlPullParser; 38 import com.android.modules.utils.TypedXmlSerializer; 39 import com.android.server.pm.PackageManagerService; 40 41 import org.xmlpull.v1.XmlPullParser; 42 import org.xmlpull.v1.XmlPullParserException; 43 44 import java.io.IOException; 45 import java.io.PrintWriter; 46 import java.util.ArrayList; 47 import java.util.Collection; 48 import java.util.Date; 49 import java.util.List; 50 import java.util.Map; 51 import java.util.Objects; 52 import java.util.Set; 53 54 /** 55 * NotificationManagerService helper for handling snoozed notifications. 56 */ 57 public final class SnoozeHelper { 58 public static final int XML_SNOOZED_NOTIFICATION_VERSION = 1; 59 60 static final int CONCURRENT_SNOOZE_LIMIT = 500; 61 62 // A safe size for strings to be put in persistent storage, to avoid breaking the XML write. 63 static final int MAX_STRING_LENGTH = 1000; 64 65 protected static final String XML_TAG_NAME = "snoozed-notifications"; 66 67 private static final String XML_SNOOZED_NOTIFICATION = "notification"; 68 private static final String XML_SNOOZED_NOTIFICATION_CONTEXT = "context"; 69 private static final String XML_SNOOZED_NOTIFICATION_KEY = "key"; 70 //the time the snoozed notification should be reposted 71 private static final String XML_SNOOZED_NOTIFICATION_TIME = "time"; 72 private static final String XML_SNOOZED_NOTIFICATION_CONTEXT_ID = "id"; 73 private static final String XML_SNOOZED_NOTIFICATION_VERSION_LABEL = "version"; 74 75 76 private static final String TAG = "SnoozeHelper"; 77 private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); 78 private static final String INDENT = " "; 79 80 private static final String REPOST_ACTION = SnoozeHelper.class.getSimpleName() + ".EVALUATE"; 81 private static final int REQUEST_CODE_REPOST = 1; 82 private static final String REPOST_SCHEME = "repost"; 83 static final String EXTRA_KEY = "key"; 84 private static final String EXTRA_USER_ID = "userId"; 85 86 private final Context mContext; 87 private AlarmManager mAm; 88 private final ManagedServices.UserProfiles mUserProfiles; 89 90 // notification key : record. 91 private ArrayMap<String, NotificationRecord> mSnoozedNotifications = new ArrayMap<>(); 92 // notification key : time-milliseconds . 93 // This member stores persisted snoozed notification trigger times. it persists through reboots 94 // It should have the notifications that haven't expired or re-posted yet 95 private final ArrayMap<String, Long> mPersistedSnoozedNotifications = new ArrayMap<>(); 96 // notification key : creation ID. 97 // This member stores persisted snoozed notification trigger context for the assistant 98 // it persists through reboots. 99 // It should have the notifications that haven't expired or re-posted yet 100 private final ArrayMap<String, String> 101 mPersistedSnoozedNotificationsWithContext = new ArrayMap<>(); 102 103 private Callback mCallback; 104 105 private final Object mLock = new Object(); 106 SnoozeHelper(Context context, Callback callback, ManagedServices.UserProfiles userProfiles)107 public SnoozeHelper(Context context, Callback callback, 108 ManagedServices.UserProfiles userProfiles) { 109 mContext = context; 110 IntentFilter filter = new IntentFilter(REPOST_ACTION); 111 filter.addDataScheme(REPOST_SCHEME); 112 mContext.registerReceiver(mBroadcastReceiver, filter, 113 Context.RECEIVER_EXPORTED_UNAUDITED); 114 mAm = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE); 115 mCallback = callback; 116 mUserProfiles = userProfiles; 117 } 118 canSnooze(int numberToSnooze)119 protected boolean canSnooze(int numberToSnooze) { 120 synchronized (mLock) { 121 if ((mSnoozedNotifications.size() + numberToSnooze) > CONCURRENT_SNOOZE_LIMIT 122 || (mPersistedSnoozedNotifications.size() 123 + mPersistedSnoozedNotificationsWithContext.size() + numberToSnooze) 124 > CONCURRENT_SNOOZE_LIMIT) { 125 return false; 126 } 127 } 128 return true; 129 } 130 131 @NonNull getSnoozeTimeForUnpostedNotification(int userId, String pkg, String key)132 protected Long getSnoozeTimeForUnpostedNotification(int userId, String pkg, String key) { 133 Long time = null; 134 synchronized (mLock) { 135 time = mPersistedSnoozedNotifications.get(getTrimmedString(key)); 136 } 137 if (time == null) { 138 time = 0L; 139 } 140 return time; 141 } 142 getSnoozeContextForUnpostedNotification(int userId, String pkg, String key)143 protected String getSnoozeContextForUnpostedNotification(int userId, String pkg, String key) { 144 synchronized (mLock) { 145 return mPersistedSnoozedNotificationsWithContext.get(getTrimmedString(key)); 146 } 147 } 148 isSnoozed(int userId, String pkg, String key)149 protected boolean isSnoozed(int userId, String pkg, String key) { 150 synchronized (mLock) { 151 return mSnoozedNotifications.containsKey(key); 152 } 153 } 154 getSnoozed(int userId, String pkg)155 protected Collection<NotificationRecord> getSnoozed(int userId, String pkg) { 156 synchronized (mLock) { 157 ArrayList snoozed = new ArrayList(); 158 for (NotificationRecord r : mSnoozedNotifications.values()) { 159 if (r.getUserId() == userId && r.getSbn().getPackageName().equals(pkg)) { 160 snoozed.add(r); 161 } 162 } 163 return snoozed; 164 } 165 } 166 167 @NonNull getNotifications(String pkg, String groupKey, Integer userId)168 ArrayList<NotificationRecord> getNotifications(String pkg, 169 String groupKey, Integer userId) { 170 ArrayList<NotificationRecord> records = new ArrayList<>(); 171 synchronized (mLock) { 172 for (int i = 0; i < mSnoozedNotifications.size(); i++) { 173 NotificationRecord r = mSnoozedNotifications.valueAt(i); 174 if (r.getSbn().getPackageName().equals(pkg) && r.getUserId() == userId 175 && Objects.equals(r.getSbn().getGroup(), groupKey)) { 176 records.add(r); 177 } 178 } 179 } 180 return records; 181 } 182 getNotification(String key)183 protected NotificationRecord getNotification(String key) { 184 synchronized (mLock) { 185 return mSnoozedNotifications.get(key); 186 } 187 } 188 getSnoozed()189 protected @NonNull List<NotificationRecord> getSnoozed() { 190 synchronized (mLock) { 191 // caller filters records based on the current user profiles and listener access, 192 // so just return everything 193 List<NotificationRecord> snoozed = new ArrayList<>(); 194 snoozed.addAll(mSnoozedNotifications.values()); 195 return snoozed; 196 } 197 } 198 199 /** 200 * Snoozes a notification and schedules an alarm to repost at that time. 201 */ snooze(NotificationRecord record, long duration)202 protected void snooze(NotificationRecord record, long duration) { 203 String key = record.getKey(); 204 205 snooze(record); 206 scheduleRepost(key, duration); 207 Long activateAt = System.currentTimeMillis() + duration; 208 synchronized (mLock) { 209 mPersistedSnoozedNotifications.put(getTrimmedString(key), activateAt); 210 } 211 } 212 213 /** 214 * Records a snoozed notification. 215 */ snooze(NotificationRecord record, String contextId)216 protected void snooze(NotificationRecord record, String contextId) { 217 if (contextId != null) { 218 synchronized (mLock) { 219 mPersistedSnoozedNotificationsWithContext.put( 220 getTrimmedString(record.getKey()), 221 getTrimmedString(contextId) 222 ); 223 } 224 } 225 snooze(record); 226 } 227 snooze(NotificationRecord record)228 private void snooze(NotificationRecord record) { 229 if (DEBUG) { 230 Slog.d(TAG, "Snoozing " + record.getKey()); 231 } 232 synchronized (mLock) { 233 mSnoozedNotifications.put(record.getKey(), record); 234 } 235 } 236 getTrimmedString(String key)237 private String getTrimmedString(String key) { 238 if (key != null && key.length() > MAX_STRING_LENGTH) { 239 return key.substring(0, MAX_STRING_LENGTH); 240 } 241 return key; 242 } 243 cancel(int userId, String pkg, String tag, int id)244 protected boolean cancel(int userId, String pkg, String tag, int id) { 245 synchronized (mLock) { 246 final Set<Map.Entry<String, NotificationRecord>> records = 247 mSnoozedNotifications.entrySet(); 248 for (Map.Entry<String, NotificationRecord> record : records) { 249 final StatusBarNotification sbn = record.getValue().getSbn(); 250 if (sbn.getPackageName().equals(pkg) && sbn.getUserId() == userId 251 && Objects.equals(sbn.getTag(), tag) && sbn.getId() == id) { 252 record.getValue().isCanceled = true; 253 return true; 254 } 255 } 256 } 257 return false; 258 } 259 cancel(int userId, boolean includeCurrentProfiles)260 protected void cancel(int userId, boolean includeCurrentProfiles) { 261 synchronized (mLock) { 262 if (mSnoozedNotifications.size() == 0) { 263 return; 264 } 265 IntArray userIds = new IntArray(); 266 userIds.add(userId); 267 if (includeCurrentProfiles) { 268 userIds = mUserProfiles.getCurrentProfileIds(); 269 } 270 for (NotificationRecord r : mSnoozedNotifications.values()) { 271 if (userIds.binarySearch(r.getUserId()) >= 0) { 272 r.isCanceled = true; 273 } 274 } 275 } 276 } 277 cancel(int userId, String pkg)278 protected boolean cancel(int userId, String pkg) { 279 synchronized (mLock) { 280 int n = mSnoozedNotifications.size(); 281 for (int i = 0; i < n; i++) { 282 final NotificationRecord r = mSnoozedNotifications.valueAt(i); 283 if (r.getSbn().getPackageName().equals(pkg) && r.getUserId() == userId) { 284 r.isCanceled = true; 285 } 286 } 287 return true; 288 } 289 } 290 291 /** 292 * Updates the notification record so the most up to date information is shown on re-post. 293 */ update(int userId, NotificationRecord record)294 protected void update(int userId, NotificationRecord record) { 295 synchronized (mLock) { 296 if (mSnoozedNotifications.containsKey(record.getKey())) { 297 mSnoozedNotifications.put(record.getKey(), record); 298 } 299 } 300 } 301 302 /** 303 * Unsnooze & repost all snoozed notifications for userId and its profiles 304 */ repostAll(IntArray userIds)305 protected void repostAll(IntArray userIds) { 306 synchronized (mLock) { 307 List<NotificationRecord> snoozedNotifications = getSnoozed(); 308 for (NotificationRecord r : snoozedNotifications) { 309 if (userIds.binarySearch(r.getUserId()) >= 0) { 310 repost(r.getKey(), r.getUserId(), false); 311 } 312 } 313 } 314 } 315 repost(String key, boolean muteOnReturn)316 protected void repost(String key, boolean muteOnReturn) { 317 synchronized (mLock) { 318 final NotificationRecord r = mSnoozedNotifications.get(key); 319 if (r != null) { 320 repost(key, r.getUserId(), muteOnReturn); 321 } 322 } 323 } 324 repost(String key, int userId, boolean muteOnReturn)325 protected void repost(String key, int userId, boolean muteOnReturn) { 326 final String trimmedKey = getTrimmedString(key); 327 328 NotificationRecord record; 329 synchronized (mLock) { 330 mPersistedSnoozedNotifications.remove(trimmedKey); 331 mPersistedSnoozedNotificationsWithContext.remove(trimmedKey); 332 record = mSnoozedNotifications.remove(key); 333 } 334 335 if (record != null && !record.isCanceled) { 336 final PendingIntent pi = createPendingIntent(record.getKey()); 337 mAm.cancel(pi); 338 MetricsLogger.action(record.getLogMaker() 339 .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED) 340 .setType(MetricsProto.MetricsEvent.TYPE_OPEN)); 341 mCallback.repost(record.getUserId(), record, muteOnReturn); 342 } 343 } 344 repostGroupSummary(String pkg, int userId, String groupKey)345 protected void repostGroupSummary(String pkg, int userId, String groupKey) { 346 synchronized (mLock) { 347 String groupSummaryKey = null; 348 int n = mSnoozedNotifications.size(); 349 for (int i = 0; i < n; i++) { 350 final NotificationRecord potentialGroupSummary = mSnoozedNotifications.valueAt(i); 351 if (potentialGroupSummary.getSbn().getPackageName().equals(pkg) 352 && potentialGroupSummary.getUserId() == userId 353 && potentialGroupSummary.getSbn().isGroup() 354 && potentialGroupSummary.getNotification().isGroupSummary() 355 && groupKey.equals(potentialGroupSummary.getGroupKey())) { 356 groupSummaryKey = potentialGroupSummary.getKey(); 357 break; 358 } 359 } 360 361 if (groupSummaryKey != null) { 362 NotificationRecord record = mSnoozedNotifications.remove(groupSummaryKey); 363 String trimmedKey = getTrimmedString(groupSummaryKey); 364 mPersistedSnoozedNotificationsWithContext.remove(trimmedKey); 365 mPersistedSnoozedNotifications.remove(trimmedKey); 366 367 if (record != null && !record.isCanceled) { 368 Runnable runnable = () -> { 369 MetricsLogger.action(record.getLogMaker() 370 .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED) 371 .setType(MetricsProto.MetricsEvent.TYPE_OPEN)); 372 mCallback.repost(record.getUserId(), record, false); 373 }; 374 runnable.run(); 375 } 376 } 377 } 378 } 379 clearData(int userId, String pkg)380 protected void clearData(int userId, String pkg) { 381 synchronized (mLock) { 382 int n = mSnoozedNotifications.size(); 383 for (int i = n - 1; i >= 0; i--) { 384 final NotificationRecord record = mSnoozedNotifications.valueAt(i); 385 if (record.getUserId() == userId && record.getSbn().getPackageName().equals(pkg)) { 386 mSnoozedNotifications.removeAt(i); 387 String trimmedKey = getTrimmedString(record.getKey()); 388 mPersistedSnoozedNotificationsWithContext.remove(trimmedKey); 389 mPersistedSnoozedNotifications.remove(trimmedKey); 390 Runnable runnable = () -> { 391 final PendingIntent pi = createPendingIntent(record.getKey()); 392 mAm.cancel(pi); 393 MetricsLogger.action(record.getLogMaker() 394 .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED) 395 .setType(MetricsProto.MetricsEvent.TYPE_DISMISS)); 396 }; 397 runnable.run(); 398 } 399 } 400 } 401 } 402 clearData(int userId)403 protected void clearData(int userId) { 404 synchronized (mLock) { 405 int n = mSnoozedNotifications.size(); 406 for (int i = n - 1; i >= 0; i--) { 407 final NotificationRecord record = mSnoozedNotifications.valueAt(i); 408 if (record.getUserId() == userId) { 409 mSnoozedNotifications.removeAt(i); 410 String trimmedKey = getTrimmedString(record.getKey()); 411 mPersistedSnoozedNotificationsWithContext.remove(trimmedKey); 412 mPersistedSnoozedNotifications.remove(trimmedKey); 413 414 Runnable runnable = () -> { 415 final PendingIntent pi = createPendingIntent(record.getKey()); 416 mAm.cancel(pi); 417 MetricsLogger.action(record.getLogMaker() 418 .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED) 419 .setType(MetricsProto.MetricsEvent.TYPE_DISMISS)); 420 }; 421 runnable.run(); 422 } 423 } 424 } 425 } 426 createPendingIntent(String key)427 private PendingIntent createPendingIntent(String key) { 428 return PendingIntent.getBroadcast(mContext, 429 REQUEST_CODE_REPOST, 430 new Intent(REPOST_ACTION) 431 .setPackage(PackageManagerService.PLATFORM_PACKAGE_NAME) 432 .setData(new Uri.Builder().scheme(REPOST_SCHEME).appendPath(key).build()) 433 .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) 434 .putExtra(EXTRA_KEY, key), 435 PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); 436 } 437 scheduleRepostsForPersistedNotifications(long currentTime)438 public void scheduleRepostsForPersistedNotifications(long currentTime) { 439 synchronized (mLock) { 440 for (int i = 0; i < mPersistedSnoozedNotifications.size(); i++) { 441 String key = mPersistedSnoozedNotifications.keyAt(i); 442 Long time = mPersistedSnoozedNotifications.valueAt(i); 443 if (time != null && time > currentTime) { 444 scheduleRepostAtTime(key, time); 445 } 446 } 447 } 448 } 449 scheduleRepost(String key, long duration)450 private void scheduleRepost(String key, long duration) { 451 scheduleRepostAtTime(key, System.currentTimeMillis() + duration); 452 } 453 scheduleRepostAtTime(String key, long time)454 private void scheduleRepostAtTime(String key, long time) { 455 Runnable runnable = () -> { 456 final long identity = Binder.clearCallingIdentity(); 457 try { 458 final PendingIntent pi = createPendingIntent(key); 459 mAm.cancel(pi); 460 if (DEBUG) Slog.d(TAG, "Scheduling evaluate for " + new Date(time)); 461 mAm.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pi); 462 } finally { 463 Binder.restoreCallingIdentity(identity); 464 } 465 }; 466 runnable.run(); 467 } 468 dump(PrintWriter pw, NotificationManagerService.DumpFilter filter)469 public void dump(PrintWriter pw, NotificationManagerService.DumpFilter filter) { 470 synchronized (mLock) { 471 pw.println("\n Snoozed notifications:"); 472 for (String key : mSnoozedNotifications.keySet()) { 473 pw.print(INDENT); 474 pw.println("key: " + key); 475 } 476 pw.println("\n Pending snoozed notifications"); 477 for (String key : mPersistedSnoozedNotifications.keySet()) { 478 pw.print(INDENT); 479 pw.println("key: " + key + " until: " + mPersistedSnoozedNotifications.get(key)); 480 } 481 } 482 } 483 writeXml(TypedXmlSerializer out)484 protected void writeXml(TypedXmlSerializer out) throws IOException { 485 synchronized (mLock) { 486 final long currentTime = System.currentTimeMillis(); 487 out.startTag(null, XML_TAG_NAME); 488 writeXml(out, mPersistedSnoozedNotifications, XML_SNOOZED_NOTIFICATION, 489 value -> { 490 if (value < currentTime) { 491 return; 492 } 493 out.attributeLong(null, XML_SNOOZED_NOTIFICATION_TIME, 494 value); 495 }); 496 writeXml(out, mPersistedSnoozedNotificationsWithContext, 497 XML_SNOOZED_NOTIFICATION_CONTEXT, 498 value -> { 499 out.attribute(null, XML_SNOOZED_NOTIFICATION_CONTEXT_ID, 500 value); 501 }); 502 out.endTag(null, XML_TAG_NAME); 503 } 504 } 505 506 private interface Inserter<T> { insert(T t)507 void insert(T t) throws IOException; 508 } 509 writeXml(TypedXmlSerializer out, ArrayMap<String, T> targets, String tag, Inserter<T> attributeInserter)510 private <T> void writeXml(TypedXmlSerializer out, ArrayMap<String, T> targets, String tag, 511 Inserter<T> attributeInserter) throws IOException { 512 for (int j = 0; j < targets.size(); j++) { 513 String key = targets.keyAt(j); 514 // T is a String (snoozed until context) or Long (snoozed until time) 515 T value = targets.valueAt(j); 516 517 out.startTag(null, tag); 518 519 attributeInserter.insert(value); 520 521 out.attributeInt(null, XML_SNOOZED_NOTIFICATION_VERSION_LABEL, 522 XML_SNOOZED_NOTIFICATION_VERSION); 523 out.attribute(null, XML_SNOOZED_NOTIFICATION_KEY, key); 524 525 out.endTag(null, tag); 526 } 527 } 528 readXml(TypedXmlPullParser parser, long currentTime)529 protected void readXml(TypedXmlPullParser parser, long currentTime) 530 throws XmlPullParserException, IOException { 531 int type; 532 while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) { 533 String tag = parser.getName(); 534 if (type == XmlPullParser.END_TAG 535 && XML_TAG_NAME.equals(tag)) { 536 break; 537 } 538 if (type == XmlPullParser.START_TAG 539 && (XML_SNOOZED_NOTIFICATION.equals(tag) 540 || tag.equals(XML_SNOOZED_NOTIFICATION_CONTEXT)) 541 && parser.getAttributeInt(null, XML_SNOOZED_NOTIFICATION_VERSION_LABEL, -1) 542 == XML_SNOOZED_NOTIFICATION_VERSION) { 543 try { 544 final String key = parser.getAttributeValue(null, XML_SNOOZED_NOTIFICATION_KEY); 545 if (tag.equals(XML_SNOOZED_NOTIFICATION)) { 546 final Long time = parser.getAttributeLong( 547 null, XML_SNOOZED_NOTIFICATION_TIME, 0); 548 if (time > currentTime) { //only read new stuff 549 synchronized (mLock) { 550 mPersistedSnoozedNotifications.put(key, time); 551 } 552 } 553 } 554 if (tag.equals(XML_SNOOZED_NOTIFICATION_CONTEXT)) { 555 final String creationId = parser.getAttributeValue( 556 null, XML_SNOOZED_NOTIFICATION_CONTEXT_ID); 557 synchronized (mLock) { 558 mPersistedSnoozedNotificationsWithContext.put(key, creationId); 559 } 560 } 561 } catch (Exception e) { 562 Slog.e(TAG, "Exception in reading snooze data from policy xml", e); 563 } 564 } 565 } 566 } 567 568 @VisibleForTesting setAlarmManager(AlarmManager am)569 void setAlarmManager(AlarmManager am) { 570 mAm = am; 571 } 572 573 protected interface Callback { repost(int userId, NotificationRecord r, boolean muteOnReturn)574 void repost(int userId, NotificationRecord r, boolean muteOnReturn); 575 } 576 577 private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() { 578 @Override 579 public void onReceive(Context context, Intent intent) { 580 if (DEBUG) { 581 Slog.d(TAG, "Reposting notification"); 582 } 583 if (REPOST_ACTION.equals(intent.getAction())) { 584 repost(intent.getStringExtra(EXTRA_KEY), intent.getIntExtra(EXTRA_USER_ID, 585 UserHandle.USER_SYSTEM), false); 586 } 587 } 588 }; 589 } 590