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