Add support for snoozing notifications

To a notification listener, snoozing will appear as a cancel
(with reason snoozed) followed by a post (when the snooze period
ends).

Apps can repost a snoozed notification, but the updates will not be shown
to the user until the snooze period ends.

Snoozing is canceled if the posting app or a notification listener
cancels the notification.

Any notification listener can snooze a notification. Technically apps
can snooze their own notifications also, though that's not public.

In this iteration snoozed notifications will be lost on device reboot.

Test: included. Also, various post, snooze, update, cancel tests with
a listener.

Bug: 30997603
Change-Id: I568a6448196f0992a17d20a4dac296321ec5092b
diff --git a/api/current.txt b/api/current.txt
index 9f5a8e2..a2d24e0 100644
--- a/api/current.txt
+++ b/api/current.txt
@@ -34853,6 +34853,7 @@
     method public static void requestRebind(android.content.ComponentName);
     method public final void requestUnbind();
     method public final void setNotificationsShown(java.lang.String[]);
+    method public final void snoozeNotification(java.lang.String, long);
     field public static final int HINT_HOST_DISABLE_CALL_EFFECTS = 4; // 0x4
     field public static final int HINT_HOST_DISABLE_EFFECTS = 1; // 0x1
     field public static final int HINT_HOST_DISABLE_NOTIFICATION_EFFECTS = 2; // 0x2
diff --git a/api/system-current.txt b/api/system-current.txt
index 4d7c4af..ffd8f3e 100644
--- a/api/system-current.txt
+++ b/api/system-current.txt
@@ -37633,6 +37633,7 @@
     method public final void requestUnbind();
     method public final void setNotificationsShown(java.lang.String[]);
     method public final void setOnNotificationPostedTrim(int);
+    method public final void snoozeNotification(java.lang.String, long);
     method public void unregisterAsSystemService() throws android.os.RemoteException;
     field public static final int HINT_HOST_DISABLE_CALL_EFFECTS = 4; // 0x4
     field public static final int HINT_HOST_DISABLE_EFFECTS = 1; // 0x1
@@ -37694,6 +37695,7 @@
     field public static final int REASON_PACKAGE_CHANGED = 5; // 0x5
     field public static final int REASON_PACKAGE_SUSPENDED = 14; // 0xe
     field public static final int REASON_PROFILE_TURNED_OFF = 15; // 0xf
+    field public static final int REASON_SNOOZED = 18; // 0x12
     field public static final int REASON_UNAUTOBUNDLED = 16; // 0x10
     field public static final int REASON_USER_STOPPED = 6; // 0x6
     field public static final java.lang.String SERVICE_INTERFACE = "android.service.notification.NotificationRankerService";
diff --git a/api/test-current.txt b/api/test-current.txt
index 4be2e7f..6f039b2 100644
--- a/api/test-current.txt
+++ b/api/test-current.txt
@@ -34934,6 +34934,7 @@
     method public static void requestRebind(android.content.ComponentName);
     method public final void requestUnbind();
     method public final void setNotificationsShown(java.lang.String[]);
+    method public final void snoozeNotification(java.lang.String, long);
     field public static final int HINT_HOST_DISABLE_CALL_EFFECTS = 4; // 0x4
     field public static final int HINT_HOST_DISABLE_EFFECTS = 1; // 0x1
     field public static final int HINT_HOST_DISABLE_NOTIFICATION_EFFECTS = 2; // 0x2
diff --git a/core/java/android/app/INotificationManager.aidl b/core/java/android/app/INotificationManager.aidl
index 28224e8..c87eef9 100644
--- a/core/java/android/app/INotificationManager.aidl
+++ b/core/java/android/app/INotificationManager.aidl
@@ -78,6 +78,8 @@
     void cancelNotificationFromListener(in INotificationListener token, String pkg, String tag, int id);
     void cancelNotificationsFromListener(in INotificationListener token, in String[] keys);
 
+    void snoozeNotificationFromListener(in INotificationListener token, String key, long until);
+
     void requestBindListener(in ComponentName component);
     void requestUnbindListener(in INotificationListener token);
     void requestBindProvider(in ComponentName component);
diff --git a/core/java/android/service/notification/NotificationListenerService.java b/core/java/android/service/notification/NotificationListenerService.java
index b13e162..e6f58f5 100644
--- a/core/java/android/service/notification/NotificationListenerService.java
+++ b/core/java/android/service/notification/NotificationListenerService.java
@@ -450,6 +450,28 @@
     }
 
     /**
+     * Inform the notification manager about snoozing a specific notification.
+     * <p>
+     * Use this if your listener has a user interface that allows the user to snooze a notification
+     * until a given time.  It should be called after the user snoozes a single notification using
+     * your UI; upon being informed, the notification manager will actually remove the notification
+     * and you will get an {@link #onNotificationRemoved(StatusBarNotification)} callback. When the
+     * snoozing period expires, you will get a
+     * {@link #onNotificationPosted(StatusBarNotification, RankingMap)} callback for the
+     * notification.
+     * @param key The key of the notification to snooze
+     * @param snoozeUntil A time in the future, in milliseconds.
+     */
+    public final void snoozeNotification(String key, long snoozeUntil) {
+        if (!isBound()) return;
+        try {
+            getNotificationInterface().snoozeNotificationFromListener(mWrapper, key, snoozeUntil);
+        } catch (android.os.RemoteException ex) {
+            Log.v(TAG, "Unable to contact notification manager", ex);
+        }
+    }
+
+    /**
      * Inform the notification manager that these notifications have been viewed by the
      * user. This should only be called when there is sufficient confidence that the user is
      * looking at the notifications, such as when the notifications appear on the screen due to
diff --git a/core/java/android/service/notification/NotificationRankerService.java b/core/java/android/service/notification/NotificationRankerService.java
index 261d82d..928d5d8 100644
--- a/core/java/android/service/notification/NotificationRankerService.java
+++ b/core/java/android/service/notification/NotificationRankerService.java
@@ -102,6 +102,9 @@
     /** Notification was canceled by the user banning the channel. */
     public static final int REASON_CHANNEL_BANNED = 17;
 
+    /** Notification was snoozed. */
+    public static final int REASON_SNOOZED = 18;
+
     private Handler mHandler;
 
     /** @hide */
diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml
index 0942a24..9d551a4 100644
--- a/core/res/AndroidManifest.xml
+++ b/core/res/AndroidManifest.xml
@@ -436,6 +436,7 @@
 
     <protected-broadcast android:name="ScheduleConditionProvider.EVALUATE" />
     <protected-broadcast android:name="EventConditionProvider.EVALUATE" />
+    <protected-broadcast android:name="SnoozeHelper.EVALUATE" />
     <protected-broadcast android:name="wifi_scan_available" />
 
     <protected-broadcast android:name="action.cne.started" />
diff --git a/services/core/java/com/android/server/notification/NotificationManagerService.java b/services/core/java/com/android/server/notification/NotificationManagerService.java
index 4d72d8e..61bf3bd 100644
--- a/services/core/java/com/android/server/notification/NotificationManagerService.java
+++ b/services/core/java/com/android/server/notification/NotificationManagerService.java
@@ -30,6 +30,7 @@
 import static android.service.notification.NotificationRankerService.REASON_PACKAGE_CHANGED;
 import static android.service.notification.NotificationRankerService.REASON_PACKAGE_SUSPENDED;
 import static android.service.notification.NotificationRankerService.REASON_PROFILE_TURNED_OFF;
+import static android.service.notification.NotificationRankerService.REASON_SNOOZED;
 import static android.service.notification.NotificationRankerService.REASON_UNAUTOBUNDLED;
 import static android.service.notification.NotificationRankerService.REASON_USER_STOPPED;
 import static android.service.notification.NotificationListenerService.HINT_HOST_DISABLE_EFFECTS;
@@ -309,6 +310,8 @@
     private float mMaxPackageEnqueueRate = DEFAULT_MAX_NOTIFICATION_ENQUEUE_RATE;
     private String mSystemNotificationSound;
 
+    private SnoozeHelper mSnoozeHelper;
+
     private static class Archive {
         final int mBufferSize;
         final ArrayDeque<StatusBarNotification> mBuffer;
@@ -999,6 +1002,22 @@
                 sendRegisteredOnlyBroadcast(NotificationManager.ACTION_NOTIFICATION_POLICY_CHANGED);
             }
         });
+        mSnoozeHelper = new SnoozeHelper(getContext(), new SnoozeHelper.Callback() {
+            @Override
+            public void repost(int userId, NotificationRecord r) {
+                try {
+                    if (DBG) {
+                        Slog.d(TAG, "Reposting " + r.getKey());
+                    }
+                    enqueueNotificationInternal(r.sbn.getPackageName(), r.sbn.getOpPkg(),
+                            r.sbn.getUid(), r.sbn.getInitialPid(), r.sbn.getTag(), r.sbn.getId(),
+                            r.sbn.getNotification(), new int[1], userId);
+                } catch (Exception e) {
+                    Slog.e(TAG, "Cannot un-snooze notification", e);
+                }
+            }
+        }, mUserProfiles);
+
         final File systemDir = new File(Environment.getDataDirectory(), "system");
         mPolicyFile = new AtomicFile(new File(systemDir, "notification_policy.xml"));
 
@@ -1810,6 +1829,13 @@
             }
         }
 
+        /**
+         * Allow an INotificationListener to simulate clearing (dismissing) a single notification.
+         *
+         * {@see com.android.server.StatusBarManagerService.NotificationCallbacks#onNotificationClear}
+         *
+         * @param token The binder for the listener, to check that the caller is allowed
+         */
         private void cancelNotificationFromListenerLocked(ManagedServiceInfo info,
                 int callingUid, int callingPid, String pkg, String tag, int id, int userId) {
             cancelNotification(callingUid, callingPid, pkg, tag, id, 0,
@@ -1819,6 +1845,23 @@
         }
 
         /**
+         * Allow an INotificationListener to snooze a single notification.
+         *
+         * @param token The binder for the listener, to check that the caller is allowed
+         */
+        @Override
+        public void snoozeNotificationFromListener(INotificationListener token, String key,
+                long snoozeUntil) {
+            long identity = Binder.clearCallingIdentity();
+            try {
+                final ManagedServiceInfo info = mListeners.checkServiceTokenLocked(token);
+                snoozeNotificationInt(key, snoozeUntil, info);
+            } finally {
+                Binder.restoreCallingIdentity(identity);
+            }
+        }
+
+        /**
          * Allow an INotificationListener to simulate clearing (dismissing) a single notification.
          *
          * {@see com.android.server.StatusBarManagerService.NotificationCallbacks#onNotificationClear}
@@ -2592,6 +2635,11 @@
                 pw.println("\n  Notification ranker services:");
                 mRankerServices.dump(pw, filter);
             }
+
+            if (!zenOnly) {
+                mSnoozeHelper.dump(pw, filter);
+            }
+
             pw.println("\n  Policy access:");
             pw.print("    mPolicyAccess: "); pw.println(mPolicyAccess);
 
@@ -2765,6 +2813,16 @@
         public void run() {
 
             synchronized (mNotificationList) {
+                if (mSnoozeHelper.isSnoozed(userId, r.sbn.getPackageName(), r.getKey())) {
+                    // TODO: log to event log
+                    if (DBG) {
+                        Slog.d(TAG, "Ignored enqueue for snoozed notification " + r.getKey());
+                    }
+                    mSnoozeHelper.update(userId, r);
+                    savePolicyFile();
+                    return;
+                }
+
                 final StatusBarNotification n = r.sbn;
                 if (DBG) Slog.d(TAG, "EnqueueNotificationRunnable.run for: " + n.getKey());
                 NotificationRecord old = mNotificationsByKey.get(n.getKey());
@@ -3578,6 +3636,11 @@
                         cancelGroupChildrenLocked(r, callingUid, callingPid, listenerName,
                                 REASON_GROUP_SUMMARY_CANCELED, sendDelete);
                         updateLightsLocked();
+                    } else {
+                        final boolean wasSnoozed = mSnoozeHelper.cancel(userId, pkg, tag, id);
+                        if (wasSnoozed) {
+                            savePolicyFile();
+                        }
                     }
                 }
             }
@@ -3654,6 +3717,7 @@
                 mNotificationList.remove(i);
                 cancelNotificationLocked(r, false, reason);
             }
+            mSnoozeHelper.cancel(userId, pkg);
             if (doit && canceledNotifications != null) {
                 final int M = canceledNotifications.size();
                 for (int i = 0; i < M; i++) {
@@ -3668,6 +3732,28 @@
         }
     }
 
+    void snoozeNotificationInt(String key, long until, ManagedServiceInfo listener) {
+        String listenerName = listener == null ? null : listener.component.toShortString();
+        // TODO: write to event log
+        if (DBG) {
+            Slog.d(TAG, String.format("snooze event(%s, %d, %s)", key, until,
+                    listenerName));
+        }
+        if (until < System.currentTimeMillis()) {
+            return;
+        }
+        synchronized (mNotificationList) {
+            final NotificationRecord r = mNotificationsByKey.get(key);
+            if (r != null) {
+                mNotificationList.remove(r);
+                cancelNotificationLocked(r, false, REASON_SNOOZED);
+                updateLightsLocked();
+                mSnoozeHelper.snooze(r, r.getUser().getIdentifier(), until);
+                savePolicyFile();
+            }
+        }
+    }
+
     void cancelAllLocked(int callingUid, int callingPid, int userId, int reason,
             ManagedServiceInfo listener, boolean includeCurrentProfiles) {
         String listenerName = listener == null ? null : listener.component.toShortString();
@@ -3699,6 +3785,7 @@
                 canceledNotifications.add(r);
             }
         }
+        mSnoozeHelper.cancel(userId, includeCurrentProfiles);
         int M = canceledNotifications != null ? canceledNotifications.size() : 0;
         for (int i = 0; i < M; i++) {
             cancelGroupChildrenLocked(canceledNotifications.get(i), callingUid, callingPid,
diff --git a/services/core/java/com/android/server/notification/SnoozeHelper.java b/services/core/java/com/android/server/notification/SnoozeHelper.java
new file mode 100644
index 0000000..738403e
--- /dev/null
+++ b/services/core/java/com/android/server/notification/SnoozeHelper.java
@@ -0,0 +1,281 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.notification;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlSerializer;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.Uri;
+import android.service.notification.StatusBarNotification;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.util.Slog;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Date;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * NotificationManagerService helper for handling snoozed notifications.
+ */
+public class SnoozeHelper {
+    private static final String TAG = "SnoozeHelper";
+    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+    private static final String INDENT = "    ";
+
+    private static final String REPOST_ACTION = SnoozeHelper.class.getSimpleName() + ".EVALUATE";
+    private static final int REQUEST_CODE_REPOST = 1;
+    private static final String REPOST_SCHEME = "repost";
+    private static final String EXTRA_PKG = "pkg";
+    private static final String EXTRA_KEY = "key";
+    private static final String EXTRA_USER_ID = "userId";
+
+    private final Context mContext;
+    private AlarmManager mAm;
+    private final ManagedServices.UserProfiles mUserProfiles;
+
+    // User id : package name : notification key : record.
+    private ArrayMap<Integer, ArrayMap<String, ArrayMap<String, NotificationRecord>>>
+            mSnoozedNotifications = new ArrayMap<>();
+    private Callback mCallback;
+
+    public SnoozeHelper(Context context, Callback callback,
+            ManagedServices.UserProfiles userProfiles) {
+        mContext = context;
+        IntentFilter filter = new IntentFilter(REPOST_ACTION);
+        filter.addDataScheme(REPOST_SCHEME);
+        mContext.registerReceiver(mBroadcastReceiver, filter);
+        mAm = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
+        mCallback = callback;
+        mUserProfiles = userProfiles;
+    }
+
+    protected boolean isSnoozed(int userId, String pkg, String key) {
+        return mSnoozedNotifications.containsKey(userId)
+                && mSnoozedNotifications.get(userId).containsKey(pkg)
+                && mSnoozedNotifications.get(userId).get(pkg).containsKey(key);
+    }
+
+    /**
+     * Records a notification that should be snoozed until the given time and schedules an alarm
+     * to repost at that time.
+     */
+    protected void snooze(NotificationRecord record, int userId, long until) {
+        ArrayMap<String, ArrayMap<String, NotificationRecord>> records =
+                mSnoozedNotifications.get(userId);
+        if (records == null) {
+            records = new ArrayMap<>();
+        }
+        ArrayMap<String, NotificationRecord> pkgRecords = records.get(record.sbn.getPackageName());
+        if (pkgRecords == null) {
+            pkgRecords = new ArrayMap<>();
+        }
+        pkgRecords.put(record.getKey(), record);
+        records.put(record.sbn.getPackageName(), pkgRecords);
+        mSnoozedNotifications.put(userId, records);
+        if (DEBUG) {
+            Slog.d(TAG, "Snoozing " + record.getKey() + " until " + new Date(until));
+        }
+        scheduleRepost(record.sbn.getPackageName(), record.getKey(), userId, until);
+    }
+
+
+    protected boolean cancel(int userId, String pkg, String tag, int id) {
+        if (mSnoozedNotifications.containsKey(userId)) {
+            ArrayMap<String, NotificationRecord> recordsForPkg =
+                    mSnoozedNotifications.get(userId).get(pkg);
+            if (recordsForPkg != null) {
+                final Set<Map.Entry<String, NotificationRecord>> records = recordsForPkg.entrySet();
+                String key = null;
+                for (Map.Entry<String, NotificationRecord> record : records) {
+                    final StatusBarNotification sbn = record.getValue().sbn;
+                    if (Objects.equals(sbn.getTag(), tag) && sbn.getId() == id) {
+                        key = record.getKey();
+                    }
+                }
+                if (key != null) {
+                    recordsForPkg.remove(key);
+                    cancelAlarm(userId, pkg, key);
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    protected boolean cancel(int userId, boolean includeCurrentProfiles) {
+        int[] userIds = {userId};
+        if (includeCurrentProfiles) {
+            userIds = mUserProfiles.getCurrentProfileIds();
+        }
+        final int N = userIds.length;
+        for (int i = 0; i < N; i++) {
+            final ArrayMap<String, ArrayMap<String, NotificationRecord>> snoozedPkgs =
+                    mSnoozedNotifications.remove(userIds[i]);
+            if (snoozedPkgs != null) {
+                final int M = snoozedPkgs.size();
+                for (int j = 0; j < M; j++) {
+                    final ArrayMap<String, NotificationRecord> records = snoozedPkgs.valueAt(j);
+                    if (records != null) {
+                        int P = records.size();
+                        for (int k = 0; k < P; k++) {
+                            cancelAlarm(userId, snoozedPkgs.keyAt(j), records.keyAt(k));
+                        }
+                    }
+                }
+                return true;
+            }
+        }
+        return false;
+    }
+
+    protected boolean cancel(int userId, String pkg) {
+        if (mSnoozedNotifications.containsKey(userId)) {
+            if (mSnoozedNotifications.get(userId).containsKey(pkg)) {
+                ArrayMap<String, NotificationRecord> records =
+                        mSnoozedNotifications.get(userId).remove(pkg);
+                int N = records.size();
+                for (int i = 0; i < N; i++) {
+                    cancelAlarm(userId, pkg, records.keyAt(i));
+                }
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private void cancelAlarm(int userId, String pkg, String key) {
+        final PendingIntent pi = createPendingIntent(pkg, key, userId);
+        mAm.cancel(pi);
+    }
+
+    /**
+     * Updates the notification record so the most up to date information is shown on re-post.
+     */
+    protected void update(int userId, NotificationRecord record) {
+        ArrayMap<String, ArrayMap<String, NotificationRecord>> records =
+                mSnoozedNotifications.get(userId);
+        if (records == null) {
+            return;
+        }
+        ArrayMap<String, NotificationRecord> pkgRecords = records.get(record.sbn.getPackageName());
+        if (pkgRecords == null) {
+            return;
+        }
+        pkgRecords.put(record.getKey(), record);
+    }
+
+    @VisibleForTesting
+    void repost(String pkg, String key, int userId) {
+        ArrayMap<String, ArrayMap<String, NotificationRecord>> records =
+                mSnoozedNotifications.get(userId);
+        if (records == null) {
+            return;
+        }
+        ArrayMap<String, NotificationRecord> pkgRecords = records.get(pkg);
+        if (pkgRecords == null) {
+            return;
+        }
+        final NotificationRecord record = pkgRecords.remove(key);
+        if (record != null) {
+            mCallback.repost(userId, record);
+        }
+    }
+
+    private PendingIntent createPendingIntent(String pkg, String key, int userId) {
+        return PendingIntent.getBroadcast(mContext,
+                REQUEST_CODE_REPOST,
+                new Intent(REPOST_ACTION)
+                        .setData(new Uri.Builder().scheme(REPOST_SCHEME).appendPath(key).build())
+                        .addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
+                        .putExtra(EXTRA_PKG, pkg)
+                        .putExtra(EXTRA_KEY, key)
+                        .putExtra(EXTRA_USER_ID, userId),
+                PendingIntent.FLAG_UPDATE_CURRENT);
+    }
+
+    private void scheduleRepost(String pkg, String key, int userId, long time) {
+        final PendingIntent pi = createPendingIntent(pkg, key, userId);
+        mAm.cancel(pi);
+        if (DEBUG) Slog.d(TAG, "Scheduling evaluate for " + new Date(time));
+        mAm.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pi);
+    }
+
+    public void dump(PrintWriter pw, NotificationManagerService.DumpFilter filter) {
+        pw.println("\n  Snoozed notifications:");
+        for (int userId : mSnoozedNotifications.keySet()) {
+            pw.print(INDENT);
+            pw.println("user: " + userId);
+            ArrayMap<String, ArrayMap<String, NotificationRecord>> snoozedPkgs =
+                    mSnoozedNotifications.get(userId);
+            for (String pkg : snoozedPkgs.keySet()) {
+                pw.print(INDENT);
+                pw.print(INDENT);
+                pw.println("package: " + pkg);
+                Set<String> snoozedKeys = snoozedPkgs.get(pkg).keySet();
+                for (String key : snoozedKeys) {
+                    pw.print(INDENT);
+                    pw.print(INDENT);
+                    pw.print(INDENT);
+                    pw.println(key);
+                }
+            }
+        }
+    }
+
+    protected void writeXml(XmlSerializer out, boolean forBackup) throws IOException {
+
+    }
+
+    public void readXml(XmlPullParser parser, boolean forRestore)
+            throws XmlPullParserException, IOException {
+
+    }
+
+    @VisibleForTesting
+    void setAlarmManager(AlarmManager am) {
+        mAm = am;
+    }
+
+    protected interface Callback {
+        void repost(int userId, NotificationRecord r);
+    }
+
+    private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            if (DEBUG) {
+                Slog.d(TAG, "Reposting notification");
+            }
+            if (REPOST_ACTION.equals(intent.getAction())) {
+                repost(intent.getStringExtra(EXTRA_PKG), intent.getStringExtra(EXTRA_KEY),
+                        intent.getIntExtra(EXTRA_USER_ID, 0));
+            }
+        }
+    };
+}
diff --git a/services/tests/servicestests/src/com/android/server/notification/SnoozeHelperTest.java b/services/tests/servicestests/src/com/android/server/notification/SnoozeHelperTest.java
new file mode 100644
index 0000000..ec1fdad
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/notification/SnoozeHelperTest.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.notification;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import android.app.AlarmManager;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.os.UserHandle;
+import android.service.notification.StatusBarNotification;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.runner.AndroidJUnit4;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class SnoozeHelperTest {
+    @Mock SnoozeHelper.Callback mCallback;
+    @Mock AlarmManager mAm;
+    @Mock ManagedServices.UserProfiles mUserProfiles;
+
+    private SnoozeHelper mSnoozeHelper;
+
+    private Context getContext() {
+        return InstrumentationRegistry.getTargetContext();
+    }
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        mSnoozeHelper = new SnoozeHelper(getContext(), mCallback, mUserProfiles);
+        mSnoozeHelper.setAlarmManager(mAm);
+    }
+
+    @Test
+    public void testSnooze() throws Exception {
+        NotificationRecord r = getNotificationRecord("pkg", 1, "one", UserHandle.SYSTEM);
+        mSnoozeHelper.snooze(r , UserHandle.USER_SYSTEM, 1000);
+        verify(mAm, times(1)).setExactAndAllowWhileIdle(
+                anyInt(), eq((long) 1000), any(PendingIntent.class));
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r.sbn.getPackageName(), r.getKey()));
+    }
+
+    @Test
+    public void testCancelByApp() throws Exception {
+        NotificationRecord r = getNotificationRecord("pkg", 1, "one", UserHandle.SYSTEM);
+        NotificationRecord r2 = getNotificationRecord("pkg", 2, "two", UserHandle.SYSTEM);
+        mSnoozeHelper.snooze(r , UserHandle.USER_SYSTEM, 1000);
+        mSnoozeHelper.snooze(r2 , UserHandle.USER_SYSTEM, 1000);
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r.sbn.getPackageName(), r.getKey()));
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r2.sbn.getPackageName(), r2.getKey()));
+
+        mSnoozeHelper.cancel(UserHandle.USER_SYSTEM, r.sbn.getPackageName(), "one", 1);
+        // 3 = one for each snooze, above + one for cancel itself.
+        verify(mAm, times(3)).cancel(any(PendingIntent.class));
+        assertFalse(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r.sbn.getPackageName(), r.getKey()));
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r2.sbn.getPackageName(), r2.getKey()));
+    }
+
+    @Test
+    public void testCancelAllForUser() throws Exception {
+        NotificationRecord r = getNotificationRecord("pkg", 1, "one", UserHandle.SYSTEM);
+        NotificationRecord r2 = getNotificationRecord("pkg", 2, "two", UserHandle.SYSTEM);
+        NotificationRecord r3 = getNotificationRecord("pkg", 3, "three", UserHandle.ALL);
+        mSnoozeHelper.snooze(r , UserHandle.USER_SYSTEM, 1000);
+        mSnoozeHelper.snooze(r2 , UserHandle.USER_SYSTEM, 1000);
+        mSnoozeHelper.snooze(r3 , UserHandle.USER_ALL, 1000);
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r.sbn.getPackageName(), r.getKey()));
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r2.sbn.getPackageName(), r2.getKey()));
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_ALL, r3.sbn.getPackageName(), r3.getKey()));
+
+        mSnoozeHelper.cancel(UserHandle.USER_SYSTEM, false);
+        // 5 = once for each snooze above (3) + once for each notification canceled (2).
+        verify(mAm, times(5)).cancel(any(PendingIntent.class));
+        assertFalse(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r.sbn.getPackageName(), r.getKey()));
+        assertFalse(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r2.sbn.getPackageName(), r2.getKey()));
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_ALL, r3.sbn.getPackageName(), r3.getKey()));
+    }
+
+    @Test
+    public void testCancelAllByApp() throws Exception {
+        NotificationRecord r = getNotificationRecord("pkg", 1, "one", UserHandle.SYSTEM);
+        NotificationRecord r2 = getNotificationRecord("pkg", 2, "two", UserHandle.SYSTEM);
+        NotificationRecord r3 = getNotificationRecord("pkg2", 3, "three", UserHandle.SYSTEM);
+        mSnoozeHelper.snooze(r , UserHandle.USER_SYSTEM, 1000);
+        mSnoozeHelper.snooze(r2 , UserHandle.USER_SYSTEM, 1000);
+        mSnoozeHelper.snooze(r3 , UserHandle.USER_SYSTEM, 1000);
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r.sbn.getPackageName(), r.getKey()));
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r2.sbn.getPackageName(), r2.getKey()));
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r3.sbn.getPackageName(), r3.getKey()));
+
+        mSnoozeHelper.cancel(UserHandle.USER_SYSTEM, "pkg2");
+        // 4 = once for each snooze above (3) + once for each notification canceled (1).
+        verify(mAm, times(4)).cancel(any(PendingIntent.class));
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r.sbn.getPackageName(), r.getKey()));
+        assertTrue(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r2.sbn.getPackageName(), r2.getKey()));
+        assertFalse(mSnoozeHelper.isSnoozed(
+                UserHandle.USER_SYSTEM, r3.sbn.getPackageName(), r3.getKey()));
+    }
+
+    @Test
+    public void testRepost() throws Exception {
+        NotificationRecord r = getNotificationRecord("pkg", 1, "one", UserHandle.SYSTEM);
+        mSnoozeHelper.snooze(r , UserHandle.USER_SYSTEM, 1000);
+        NotificationRecord r2 = getNotificationRecord("pkg", 2, "one", UserHandle.ALL);
+        mSnoozeHelper.snooze(r2 , UserHandle.USER_ALL, 1000);
+        mSnoozeHelper.repost(r.sbn.getPackageName(), r.getKey(), UserHandle.USER_SYSTEM);
+        verify(mCallback, times(1)).repost(UserHandle.USER_SYSTEM, r);
+    }
+
+    @Test
+    public void testUpdate() throws Exception {
+        NotificationRecord r = getNotificationRecord("pkg", 1, "one", UserHandle.SYSTEM);
+        mSnoozeHelper.snooze(r , UserHandle.USER_SYSTEM, 1000);
+        r.getNotification().category = "NEW CATEGORY";
+
+        mSnoozeHelper.update(UserHandle.USER_SYSTEM, r);
+        verify(mCallback, never()).repost(anyInt(), any(NotificationRecord.class));
+
+        mSnoozeHelper.repost(r.sbn.getPackageName(), r.getKey(), UserHandle.USER_SYSTEM);
+        verify(mCallback, times(1)).repost(UserHandle.USER_SYSTEM, r);
+    }
+
+    private NotificationRecord getNotificationRecord(String pkg, int id, String tag,
+            UserHandle user) {
+        Notification n = new Notification.Builder(getContext())
+                .setContentTitle("A")
+                .setGroup("G")
+                .setSortKey("A")
+                .setWhen(1205)
+                .build();
+        return new NotificationRecord(getContext(), new StatusBarNotification(
+                pkg, pkg, id, tag, 0, 0, 0, n, user),
+                new NotificationChannel(NotificationChannel.DEFAULT_CHANNEL_ID, "name"));
+    }
+
+}