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 
repost(String key, boolean muteOnReturn)302     protected void repost(String key, boolean muteOnReturn) {
303         synchronized (mLock) {
304             final NotificationRecord r = mSnoozedNotifications.get(key);
305             if (r != null) {
306                 repost(key, r.getUserId(), muteOnReturn);
307             }
308         }
309     }
310 
repost(String key, int userId, boolean muteOnReturn)311     protected void repost(String key, int userId, boolean muteOnReturn) {
312         final String trimmedKey = getTrimmedString(key);
313 
314         NotificationRecord record;
315         synchronized (mLock) {
316             mPersistedSnoozedNotifications.remove(trimmedKey);
317             mPersistedSnoozedNotificationsWithContext.remove(trimmedKey);
318             record = mSnoozedNotifications.remove(key);
319         }
320 
321         if (record != null && !record.isCanceled) {
322             final PendingIntent pi = createPendingIntent(record.getKey());
323             mAm.cancel(pi);
324             MetricsLogger.action(record.getLogMaker()
325                     .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
326                     .setType(MetricsProto.MetricsEvent.TYPE_OPEN));
327             mCallback.repost(record.getUserId(), record, muteOnReturn);
328         }
329     }
330 
repostGroupSummary(String pkg, int userId, String groupKey)331     protected void repostGroupSummary(String pkg, int userId, String groupKey) {
332         synchronized (mLock) {
333             String groupSummaryKey = null;
334             int n = mSnoozedNotifications.size();
335             for (int i = 0; i < n; i++) {
336                 final NotificationRecord potentialGroupSummary = mSnoozedNotifications.valueAt(i);
337                 if (potentialGroupSummary.getSbn().getPackageName().equals(pkg)
338                         && potentialGroupSummary.getUserId() == userId
339                         && potentialGroupSummary.getSbn().isGroup()
340                         && potentialGroupSummary.getNotification().isGroupSummary()
341                         && groupKey.equals(potentialGroupSummary.getGroupKey())) {
342                     groupSummaryKey = potentialGroupSummary.getKey();
343                     break;
344                 }
345             }
346 
347             if (groupSummaryKey != null) {
348                 NotificationRecord record = mSnoozedNotifications.remove(groupSummaryKey);
349                 String trimmedKey = getTrimmedString(groupSummaryKey);
350                 mPersistedSnoozedNotificationsWithContext.remove(trimmedKey);
351                 mPersistedSnoozedNotifications.remove(trimmedKey);
352 
353                 if (record != null && !record.isCanceled) {
354                     Runnable runnable = () -> {
355                         MetricsLogger.action(record.getLogMaker()
356                                 .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
357                                 .setType(MetricsProto.MetricsEvent.TYPE_OPEN));
358                         mCallback.repost(record.getUserId(), record, false);
359                     };
360                     runnable.run();
361                 }
362             }
363         }
364     }
365 
clearData(int userId, String pkg)366     protected void clearData(int userId, String pkg) {
367         synchronized (mLock) {
368             int n = mSnoozedNotifications.size();
369             for (int i = n - 1; i >= 0; i--) {
370                 final NotificationRecord record = mSnoozedNotifications.valueAt(i);
371                 if (record.getUserId() == userId && record.getSbn().getPackageName().equals(pkg)) {
372                     mSnoozedNotifications.removeAt(i);
373                     String trimmedKey = getTrimmedString(record.getKey());
374                     mPersistedSnoozedNotificationsWithContext.remove(trimmedKey);
375                     mPersistedSnoozedNotifications.remove(trimmedKey);
376                     Runnable runnable = () -> {
377                         final PendingIntent pi = createPendingIntent(record.getKey());
378                         mAm.cancel(pi);
379                         MetricsLogger.action(record.getLogMaker()
380                                 .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
381                                 .setType(MetricsProto.MetricsEvent.TYPE_DISMISS));
382                     };
383                     runnable.run();
384                 }
385             }
386         }
387     }
388 
clearData(int userId)389     protected void clearData(int userId) {
390         synchronized (mLock) {
391             int n = mSnoozedNotifications.size();
392             for (int i = n - 1; i >= 0; i--) {
393                 final NotificationRecord record = mSnoozedNotifications.valueAt(i);
394                 if (record.getUserId() == userId) {
395                     mSnoozedNotifications.removeAt(i);
396                     String trimmedKey = getTrimmedString(record.getKey());
397                     mPersistedSnoozedNotificationsWithContext.remove(trimmedKey);
398                     mPersistedSnoozedNotifications.remove(trimmedKey);
399 
400                     Runnable runnable = () -> {
401                         final PendingIntent pi = createPendingIntent(record.getKey());
402                         mAm.cancel(pi);
403                         MetricsLogger.action(record.getLogMaker()
404                                 .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
405                                 .setType(MetricsProto.MetricsEvent.TYPE_DISMISS));
406                     };
407                     runnable.run();
408                 }
409             }
410         }
411     }
412 
createPendingIntent(String key)413     private PendingIntent createPendingIntent(String key) {
414         return PendingIntent.getBroadcast(mContext,
415                 REQUEST_CODE_REPOST,
416                 new Intent(REPOST_ACTION)
417                         .setPackage(PackageManagerService.PLATFORM_PACKAGE_NAME)
418                         .setData(new Uri.Builder().scheme(REPOST_SCHEME).appendPath(key).build())
419                         .addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
420                         .putExtra(EXTRA_KEY, key),
421                 PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
422     }
423 
scheduleRepostsForPersistedNotifications(long currentTime)424     public void scheduleRepostsForPersistedNotifications(long currentTime) {
425         synchronized (mLock) {
426             for (int i = 0; i < mPersistedSnoozedNotifications.size(); i++) {
427                 String key = mPersistedSnoozedNotifications.keyAt(i);
428                 Long time = mPersistedSnoozedNotifications.valueAt(i);
429                 if (time != null && time > currentTime) {
430                     scheduleRepostAtTime(key, time);
431                 }
432             }
433         }
434     }
435 
scheduleRepost(String key, long duration)436     private void scheduleRepost(String key, long duration) {
437         scheduleRepostAtTime(key, System.currentTimeMillis() + duration);
438     }
439 
scheduleRepostAtTime(String key, long time)440     private void scheduleRepostAtTime(String key, long time) {
441         Runnable runnable = () -> {
442             final long identity = Binder.clearCallingIdentity();
443             try {
444                 final PendingIntent pi = createPendingIntent(key);
445                 mAm.cancel(pi);
446                 if (DEBUG) Slog.d(TAG, "Scheduling evaluate for " + new Date(time));
447                 mAm.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pi);
448             } finally {
449                 Binder.restoreCallingIdentity(identity);
450             }
451         };
452         runnable.run();
453     }
454 
dump(PrintWriter pw, NotificationManagerService.DumpFilter filter)455     public void dump(PrintWriter pw, NotificationManagerService.DumpFilter filter) {
456         synchronized (mLock) {
457             pw.println("\n  Snoozed notifications:");
458             for (String key : mSnoozedNotifications.keySet()) {
459                 pw.print(INDENT);
460                 pw.println("key: " + key);
461             }
462             pw.println("\n Pending snoozed notifications");
463             for (String key : mPersistedSnoozedNotifications.keySet()) {
464                 pw.print(INDENT);
465                 pw.println("key: " + key + " until: " + mPersistedSnoozedNotifications.get(key));
466             }
467         }
468     }
469 
writeXml(TypedXmlSerializer out)470     protected void writeXml(TypedXmlSerializer out) throws IOException {
471         synchronized (mLock) {
472             final long currentTime = System.currentTimeMillis();
473             out.startTag(null, XML_TAG_NAME);
474             writeXml(out, mPersistedSnoozedNotifications, XML_SNOOZED_NOTIFICATION,
475                     value -> {
476                         if (value < currentTime) {
477                             return;
478                         }
479                         out.attributeLong(null, XML_SNOOZED_NOTIFICATION_TIME,
480                                 value);
481                     });
482             writeXml(out, mPersistedSnoozedNotificationsWithContext,
483                     XML_SNOOZED_NOTIFICATION_CONTEXT,
484                     value -> {
485                         out.attribute(null, XML_SNOOZED_NOTIFICATION_CONTEXT_ID,
486                                 value);
487                     });
488             out.endTag(null, XML_TAG_NAME);
489         }
490     }
491 
492     private interface Inserter<T> {
insert(T t)493         void insert(T t) throws IOException;
494     }
495 
writeXml(TypedXmlSerializer out, ArrayMap<String, T> targets, String tag, Inserter<T> attributeInserter)496     private <T> void writeXml(TypedXmlSerializer out, ArrayMap<String, T> targets, String tag,
497             Inserter<T> attributeInserter) throws IOException {
498         for (int j = 0; j < targets.size(); j++) {
499             String key = targets.keyAt(j);
500             // T is a String (snoozed until context) or Long (snoozed until time)
501             T value = targets.valueAt(j);
502 
503             out.startTag(null, tag);
504 
505             attributeInserter.insert(value);
506 
507             out.attributeInt(null, XML_SNOOZED_NOTIFICATION_VERSION_LABEL,
508                     XML_SNOOZED_NOTIFICATION_VERSION);
509             out.attribute(null, XML_SNOOZED_NOTIFICATION_KEY, key);
510 
511             out.endTag(null, tag);
512         }
513     }
514 
readXml(TypedXmlPullParser parser, long currentTime)515     protected void readXml(TypedXmlPullParser parser, long currentTime)
516             throws XmlPullParserException, IOException {
517         int type;
518         while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
519             String tag = parser.getName();
520             if (type == XmlPullParser.END_TAG
521                     && XML_TAG_NAME.equals(tag)) {
522                 break;
523             }
524             if (type == XmlPullParser.START_TAG
525                     && (XML_SNOOZED_NOTIFICATION.equals(tag)
526                         || tag.equals(XML_SNOOZED_NOTIFICATION_CONTEXT))
527                     && parser.getAttributeInt(null, XML_SNOOZED_NOTIFICATION_VERSION_LABEL, -1)
528                         == XML_SNOOZED_NOTIFICATION_VERSION) {
529                 try {
530                     final String key = parser.getAttributeValue(null, XML_SNOOZED_NOTIFICATION_KEY);
531                     if (tag.equals(XML_SNOOZED_NOTIFICATION)) {
532                         final Long time = parser.getAttributeLong(
533                                 null, XML_SNOOZED_NOTIFICATION_TIME, 0);
534                         if (time > currentTime) { //only read new stuff
535                             synchronized (mLock) {
536                                 mPersistedSnoozedNotifications.put(key, time);
537                             }
538                         }
539                     }
540                     if (tag.equals(XML_SNOOZED_NOTIFICATION_CONTEXT)) {
541                         final String creationId = parser.getAttributeValue(
542                                 null, XML_SNOOZED_NOTIFICATION_CONTEXT_ID);
543                         synchronized (mLock) {
544                             mPersistedSnoozedNotificationsWithContext.put(key, creationId);
545                         }
546                     }
547                 } catch (Exception e) {
548                     Slog.e(TAG,  "Exception in reading snooze data from policy xml", e);
549                 }
550             }
551         }
552     }
553 
554     @VisibleForTesting
setAlarmManager(AlarmManager am)555     void setAlarmManager(AlarmManager am) {
556         mAm = am;
557     }
558 
559     protected interface Callback {
repost(int userId, NotificationRecord r, boolean muteOnReturn)560         void repost(int userId, NotificationRecord r, boolean muteOnReturn);
561     }
562 
563     private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
564         @Override
565         public void onReceive(Context context, Intent intent) {
566             if (DEBUG) {
567                 Slog.d(TAG, "Reposting notification");
568             }
569             if (REPOST_ACTION.equals(intent.getAction())) {
570                 repost(intent.getStringExtra(EXTRA_KEY), intent.getIntExtra(EXTRA_USER_ID,
571                         UserHandle.USER_SYSTEM), false);
572             }
573         }
574     };
575 }
576