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"));
+ }
+
+}