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.Notification;
21 import android.app.PendingIntent;
22 import android.content.BroadcastReceiver;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.content.IntentFilter;
26 import android.net.Uri;
27 import android.os.Binder;
28 import android.os.SystemClock;
29 import android.os.UserHandle;
30 import android.service.notification.StatusBarNotification;
31 import android.util.ArrayMap;
32 import android.util.IntArray;
33 import android.util.Log;
34 import android.util.Slog;
35 
36 import com.android.internal.annotations.VisibleForTesting;
37 import com.android.internal.logging.MetricsLogger;
38 import com.android.internal.logging.nano.MetricsProto;
39 
40 import org.xmlpull.v1.XmlPullParser;
41 import org.xmlpull.v1.XmlPullParserException;
42 import org.xmlpull.v1.XmlSerializer;
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.Collections;
49 import java.util.Date;
50 import java.util.List;
51 import java.util.Map;
52 import java.util.Objects;
53 import java.util.Set;
54 
55 /**
56  * NotificationManagerService helper for handling snoozed notifications.
57  */
58 public class SnoozeHelper {
59     private static final String TAG = "SnoozeHelper";
60     private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
61     private static final String INDENT = "    ";
62 
63     private static final String REPOST_ACTION = SnoozeHelper.class.getSimpleName() + ".EVALUATE";
64     private static final int REQUEST_CODE_REPOST = 1;
65     private static final String REPOST_SCHEME = "repost";
66     private static final String EXTRA_KEY = "key";
67     private static final String EXTRA_USER_ID = "userId";
68 
69     private final Context mContext;
70     private AlarmManager mAm;
71     private final ManagedServices.UserProfiles mUserProfiles;
72 
73     // User id : package name : notification key : record.
74     private ArrayMap<Integer, ArrayMap<String, ArrayMap<String, NotificationRecord>>>
75             mSnoozedNotifications = new ArrayMap<>();
76     // notification key : package.
77     private ArrayMap<String, String> mPackages = new ArrayMap<>();
78     // key : userId
79     private ArrayMap<String, Integer> mUsers = new ArrayMap<>();
80     private Callback mCallback;
81 
SnoozeHelper(Context context, Callback callback, ManagedServices.UserProfiles userProfiles)82     public SnoozeHelper(Context context, Callback callback,
83             ManagedServices.UserProfiles userProfiles) {
84         mContext = context;
85         IntentFilter filter = new IntentFilter(REPOST_ACTION);
86         filter.addDataScheme(REPOST_SCHEME);
87         mContext.registerReceiver(mBroadcastReceiver, filter);
88         mAm = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
89         mCallback = callback;
90         mUserProfiles = userProfiles;
91     }
92 
isSnoozed(int userId, String pkg, String key)93     protected boolean isSnoozed(int userId, String pkg, String key) {
94         return mSnoozedNotifications.containsKey(userId)
95                 && mSnoozedNotifications.get(userId).containsKey(pkg)
96                 && mSnoozedNotifications.get(userId).get(pkg).containsKey(key);
97     }
98 
getSnoozed(int userId, String pkg)99     protected Collection<NotificationRecord> getSnoozed(int userId, String pkg) {
100         if (mSnoozedNotifications.containsKey(userId)
101                 && mSnoozedNotifications.get(userId).containsKey(pkg)) {
102             return mSnoozedNotifications.get(userId).get(pkg).values();
103         }
104         return Collections.EMPTY_LIST;
105     }
106 
getSnoozed()107     protected @NonNull List<NotificationRecord> getSnoozed() {
108         List<NotificationRecord> snoozedForUser = new ArrayList<>();
109         IntArray userIds = mUserProfiles.getCurrentProfileIds();
110         if (userIds != null) {
111             final int N = userIds.size();
112             for (int i = 0; i < N; i++) {
113                 final ArrayMap<String, ArrayMap<String, NotificationRecord>> snoozedPkgs =
114                         mSnoozedNotifications.get(userIds.get(i));
115                 if (snoozedPkgs != null) {
116                     final int M = snoozedPkgs.size();
117                     for (int j = 0; j < M; j++) {
118                         final ArrayMap<String, NotificationRecord> records = snoozedPkgs.valueAt(j);
119                         if (records != null) {
120                             snoozedForUser.addAll(records.values());
121                         }
122                     }
123                 }
124             }
125         }
126         return snoozedForUser;
127     }
128 
129     /**
130      * Snoozes a notification and schedules an alarm to repost at that time.
131      */
snooze(NotificationRecord record, long duration)132     protected void snooze(NotificationRecord record, long duration) {
133         snooze(record);
134         scheduleRepost(record.sbn.getPackageName(), record.getKey(), record.getUserId(), duration);
135     }
136 
137     /**
138      * Records a snoozed notification.
139      */
snooze(NotificationRecord record)140     protected void snooze(NotificationRecord record) {
141         int userId = record.getUser().getIdentifier();
142         if (DEBUG) {
143             Slog.d(TAG, "Snoozing " + record.getKey());
144         }
145         ArrayMap<String, ArrayMap<String, NotificationRecord>> records =
146                 mSnoozedNotifications.get(userId);
147         if (records == null) {
148             records = new ArrayMap<>();
149         }
150         ArrayMap<String, NotificationRecord> pkgRecords = records.get(record.sbn.getPackageName());
151         if (pkgRecords == null) {
152             pkgRecords = new ArrayMap<>();
153         }
154         pkgRecords.put(record.getKey(), record);
155         records.put(record.sbn.getPackageName(), pkgRecords);
156         mSnoozedNotifications.put(userId, records);
157         mPackages.put(record.getKey(), record.sbn.getPackageName());
158         mUsers.put(record.getKey(), userId);
159     }
160 
cancel(int userId, String pkg, String tag, int id)161     protected boolean cancel(int userId, String pkg, String tag, int id) {
162         if (mSnoozedNotifications.containsKey(userId)) {
163             ArrayMap<String, NotificationRecord> recordsForPkg =
164                     mSnoozedNotifications.get(userId).get(pkg);
165             if (recordsForPkg != null) {
166                 final Set<Map.Entry<String, NotificationRecord>> records = recordsForPkg.entrySet();
167                 for (Map.Entry<String, NotificationRecord> record : records) {
168                     final StatusBarNotification sbn = record.getValue().sbn;
169                     if (Objects.equals(sbn.getTag(), tag) && sbn.getId() == id) {
170                         record.getValue().isCanceled = true;
171                         return true;
172                     }
173                 }
174             }
175         }
176         return false;
177     }
178 
cancel(int userId, boolean includeCurrentProfiles)179     protected boolean cancel(int userId, boolean includeCurrentProfiles) {
180         int[] userIds = {userId};
181         if (includeCurrentProfiles) {
182             userIds = mUserProfiles.getCurrentProfileIds().toArray();
183         }
184         final int N = userIds.length;
185         for (int i = 0; i < N; i++) {
186             final ArrayMap<String, ArrayMap<String, NotificationRecord>> snoozedPkgs =
187                     mSnoozedNotifications.get(userIds[i]);
188             if (snoozedPkgs != null) {
189                 final int M = snoozedPkgs.size();
190                 for (int j = 0; j < M; j++) {
191                     final ArrayMap<String, NotificationRecord> records = snoozedPkgs.valueAt(j);
192                     if (records != null) {
193                         int P = records.size();
194                         for (int k = 0; k < P; k++) {
195                             records.valueAt(k).isCanceled = true;
196                         }
197                     }
198                 }
199                 return true;
200             }
201         }
202         return false;
203     }
204 
cancel(int userId, String pkg)205     protected boolean cancel(int userId, String pkg) {
206         if (mSnoozedNotifications.containsKey(userId)) {
207             if (mSnoozedNotifications.get(userId).containsKey(pkg)) {
208                 ArrayMap<String, NotificationRecord> records =
209                         mSnoozedNotifications.get(userId).get(pkg);
210                 int N = records.size();
211                 for (int i = 0; i < N; i++) {
212                     records.valueAt(i).isCanceled = true;
213                 }
214                 return true;
215             }
216         }
217         return false;
218     }
219 
220     /**
221      * Updates the notification record so the most up to date information is shown on re-post.
222      */
update(int userId, NotificationRecord record)223     protected void update(int userId, NotificationRecord record) {
224         ArrayMap<String, ArrayMap<String, NotificationRecord>> records =
225                 mSnoozedNotifications.get(userId);
226         if (records == null) {
227             return;
228         }
229         ArrayMap<String, NotificationRecord> pkgRecords = records.get(record.sbn.getPackageName());
230         if (pkgRecords == null) {
231             return;
232         }
233         NotificationRecord existing = pkgRecords.get(record.getKey());
234         pkgRecords.put(record.getKey(), record);
235     }
236 
repost(String key)237     protected void repost(String key) {
238         Integer userId = mUsers.get(key);
239         if (userId != null) {
240             repost(key, userId);
241         }
242     }
243 
repost(String key, int userId)244     protected void repost(String key, int userId) {
245         final String pkg = mPackages.remove(key);
246         ArrayMap<String, ArrayMap<String, NotificationRecord>> records =
247                 mSnoozedNotifications.get(userId);
248         if (records == null) {
249             return;
250         }
251         ArrayMap<String, NotificationRecord> pkgRecords = records.get(pkg);
252         if (pkgRecords == null) {
253             return;
254         }
255         final NotificationRecord record = pkgRecords.remove(key);
256         mPackages.remove(key);
257         mUsers.remove(key);
258 
259         if (record != null && !record.isCanceled) {
260             MetricsLogger.action(record.getLogMaker()
261                     .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
262                     .setType(MetricsProto.MetricsEvent.TYPE_OPEN));
263             mCallback.repost(userId, record);
264         }
265     }
266 
repostGroupSummary(String pkg, int userId, String groupKey)267     protected void repostGroupSummary(String pkg, int userId, String groupKey) {
268         if (mSnoozedNotifications.containsKey(userId)) {
269             ArrayMap<String, ArrayMap<String, NotificationRecord>> keysByPackage
270                     = mSnoozedNotifications.get(userId);
271 
272             if (keysByPackage != null && keysByPackage.containsKey(pkg)) {
273                 ArrayMap<String, NotificationRecord> recordsByKey = keysByPackage.get(pkg);
274 
275                 if (recordsByKey != null) {
276                     String groupSummaryKey = null;
277                     int N = recordsByKey.size();
278                     for (int i = 0; i < N; i++) {
279                         final NotificationRecord potentialGroupSummary = recordsByKey.valueAt(i);
280                         if (potentialGroupSummary.sbn.isGroup()
281                                 && potentialGroupSummary.getNotification().isGroupSummary()
282                                 && groupKey.equals(potentialGroupSummary.getGroupKey())) {
283                             groupSummaryKey = potentialGroupSummary.getKey();
284                             break;
285                         }
286                     }
287 
288                     if (groupSummaryKey != null) {
289                         NotificationRecord record = recordsByKey.remove(groupSummaryKey);
290                         mPackages.remove(groupSummaryKey);
291                         mUsers.remove(groupSummaryKey);
292 
293                         if (record != null && !record.isCanceled) {
294                             MetricsLogger.action(record.getLogMaker()
295                                     .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
296                                     .setType(MetricsProto.MetricsEvent.TYPE_OPEN));
297                             mCallback.repost(userId, record);
298                         }
299                     }
300                 }
301             }
302         }
303     }
304 
clearData(int userId, String pkg)305     protected void clearData(int userId, String pkg) {
306         ArrayMap<String, ArrayMap<String, NotificationRecord>> records =
307                 mSnoozedNotifications.get(userId);
308         if (records == null) {
309             return;
310         }
311         ArrayMap<String, NotificationRecord> pkgRecords = records.get(pkg);
312         if (pkgRecords == null) {
313             return;
314         }
315         for (int i = pkgRecords.size() - 1; i >= 0; i--) {
316             final NotificationRecord r = pkgRecords.removeAt(i);
317             if (r != null) {
318                 mPackages.remove(r.getKey());
319                 mUsers.remove(r.getKey());
320                 final PendingIntent pi = createPendingIntent(pkg, r.getKey(), userId);
321                 mAm.cancel(pi);
322                 MetricsLogger.action(r.getLogMaker()
323                         .setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
324                         .setType(MetricsProto.MetricsEvent.TYPE_DISMISS));
325             }
326         }
327     }
328 
createPendingIntent(String pkg, String key, int userId)329     private PendingIntent createPendingIntent(String pkg, String key, int userId) {
330         return PendingIntent.getBroadcast(mContext,
331                 REQUEST_CODE_REPOST,
332                 new Intent(REPOST_ACTION)
333                         .setData(new Uri.Builder().scheme(REPOST_SCHEME).appendPath(key).build())
334                         .addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
335                         .putExtra(EXTRA_KEY, key)
336                         .putExtra(EXTRA_USER_ID, userId),
337                 PendingIntent.FLAG_UPDATE_CURRENT);
338     }
339 
scheduleRepost(String pkg, String key, int userId, long duration)340     private void scheduleRepost(String pkg, String key, int userId, long duration) {
341         long identity = Binder.clearCallingIdentity();
342         try {
343             final PendingIntent pi = createPendingIntent(pkg, key, userId);
344             mAm.cancel(pi);
345             long time = SystemClock.elapsedRealtime() + duration;
346             if (DEBUG) Slog.d(TAG, "Scheduling evaluate for " + new Date(time));
347             mAm.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pi);
348         } finally {
349             Binder.restoreCallingIdentity(identity);
350         }
351     }
352 
dump(PrintWriter pw, NotificationManagerService.DumpFilter filter)353     public void dump(PrintWriter pw, NotificationManagerService.DumpFilter filter) {
354         pw.println("\n  Snoozed notifications:");
355         for (int userId : mSnoozedNotifications.keySet()) {
356             pw.print(INDENT);
357             pw.println("user: " + userId);
358             ArrayMap<String, ArrayMap<String, NotificationRecord>> snoozedPkgs =
359                     mSnoozedNotifications.get(userId);
360             for (String pkg : snoozedPkgs.keySet()) {
361                 pw.print(INDENT);
362                 pw.print(INDENT);
363                 pw.println("package: " + pkg);
364                 Set<String> snoozedKeys = snoozedPkgs.get(pkg).keySet();
365                 for (String key : snoozedKeys) {
366                     pw.print(INDENT);
367                     pw.print(INDENT);
368                     pw.print(INDENT);
369                     pw.println(key);
370                 }
371             }
372         }
373     }
374 
writeXml(XmlSerializer out, boolean forBackup)375     protected void writeXml(XmlSerializer out, boolean forBackup) throws IOException {
376 
377     }
378 
readXml(XmlPullParser parser, boolean forRestore)379     public void readXml(XmlPullParser parser, boolean forRestore)
380             throws XmlPullParserException, IOException {
381 
382     }
383 
384     @VisibleForTesting
setAlarmManager(AlarmManager am)385     void setAlarmManager(AlarmManager am) {
386         mAm = am;
387     }
388 
389     protected interface Callback {
repost(int userId, NotificationRecord r)390         void repost(int userId, NotificationRecord r);
391     }
392 
393     private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
394         @Override
395         public void onReceive(Context context, Intent intent) {
396             if (DEBUG) {
397                 Slog.d(TAG, "Reposting notification");
398             }
399             if (REPOST_ACTION.equals(intent.getAction())) {
400                 repost(intent.getStringExtra(EXTRA_KEY), intent.getIntExtra(EXTRA_USER_ID,
401                         UserHandle.USER_SYSTEM));
402             }
403         }
404     };
405 }
406