blob: 12c16fc43bd3f0fd2068feb8cf4de68dcb335ff3 [file] [log] [blame]
/*
* 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 android.annotation.NonNull;
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.os.Binder;
import android.os.UserHandle;
import android.service.notification.StatusBarNotification;
import android.util.ArrayMap;
import android.util.IntArray;
import android.util.Log;
import android.util.Slog;
import android.util.TypedXmlPullParser;
import android.util.TypedXmlSerializer;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto;
import com.android.server.pm.PackageManagerService;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* NotificationManagerService helper for handling snoozed notifications.
*/
public final class SnoozeHelper {
public static final int XML_SNOOZED_NOTIFICATION_VERSION = 1;
static final int CONCURRENT_SNOOZE_LIMIT = 500;
// A safe size for strings to be put in persistent storage, to avoid breaking the XML write.
static final int MAX_STRING_LENGTH = 1000;
protected static final String XML_TAG_NAME = "snoozed-notifications";
private static final String XML_SNOOZED_NOTIFICATION = "notification";
private static final String XML_SNOOZED_NOTIFICATION_CONTEXT = "context";
private static final String XML_SNOOZED_NOTIFICATION_KEY = "key";
//the time the snoozed notification should be reposted
private static final String XML_SNOOZED_NOTIFICATION_TIME = "time";
private static final String XML_SNOOZED_NOTIFICATION_CONTEXT_ID = "id";
private static final String XML_SNOOZED_NOTIFICATION_VERSION_LABEL = "version";
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";
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;
// notification key : record.
private ArrayMap<String, NotificationRecord> mSnoozedNotifications = new ArrayMap<>();
// notification key : time-milliseconds .
// This member stores persisted snoozed notification trigger times. it persists through reboots
// It should have the notifications that haven't expired or re-posted yet
private final ArrayMap<String, Long> mPersistedSnoozedNotifications = new ArrayMap<>();
// notification key : creation ID.
// This member stores persisted snoozed notification trigger context for the assistant
// it persists through reboots.
// It should have the notifications that haven't expired or re-posted yet
private final ArrayMap<String, String>
mPersistedSnoozedNotificationsWithContext = new ArrayMap<>();
private Callback mCallback;
private final Object mLock = new Object();
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,
Context.RECEIVER_EXPORTED_UNAUDITED);
mAm = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
mCallback = callback;
mUserProfiles = userProfiles;
}
protected boolean canSnooze(int numberToSnooze) {
synchronized (mLock) {
if ((mSnoozedNotifications.size() + numberToSnooze) > CONCURRENT_SNOOZE_LIMIT) {
return false;
}
}
return true;
}
@NonNull
protected Long getSnoozeTimeForUnpostedNotification(int userId, String pkg, String key) {
Long time = null;
synchronized (mLock) {
time = mPersistedSnoozedNotifications.get(getTrimmedString(key));
}
if (time == null) {
time = 0L;
}
return time;
}
protected String getSnoozeContextForUnpostedNotification(int userId, String pkg, String key) {
synchronized (mLock) {
return mPersistedSnoozedNotificationsWithContext.get(getTrimmedString(key));
}
}
protected boolean isSnoozed(int userId, String pkg, String key) {
synchronized (mLock) {
return mSnoozedNotifications.containsKey(key);
}
}
protected Collection<NotificationRecord> getSnoozed(int userId, String pkg) {
synchronized (mLock) {
ArrayList snoozed = new ArrayList();
for (NotificationRecord r : mSnoozedNotifications.values()) {
if (r.getUserId() == userId && r.getSbn().getPackageName().equals(pkg)) {
snoozed.add(r);
}
}
return snoozed;
}
}
@NonNull
ArrayList<NotificationRecord> getNotifications(String pkg,
String groupKey, Integer userId) {
ArrayList<NotificationRecord> records = new ArrayList<>();
synchronized (mLock) {
for (int i = 0; i < mSnoozedNotifications.size(); i++) {
NotificationRecord r = mSnoozedNotifications.valueAt(i);
if (r.getSbn().getPackageName().equals(pkg) && r.getUserId() == userId
&& Objects.equals(r.getSbn().getGroup(), groupKey)) {
records.add(r);
}
}
}
return records;
}
protected NotificationRecord getNotification(String key) {
synchronized (mLock) {
return mSnoozedNotifications.get(key);
}
}
protected @NonNull List<NotificationRecord> getSnoozed() {
synchronized (mLock) {
// caller filters records based on the current user profiles and listener access,
// so just return everything
List<NotificationRecord> snoozed = new ArrayList<>();
snoozed.addAll(mSnoozedNotifications.values());
return snoozed;
}
}
/**
* Snoozes a notification and schedules an alarm to repost at that time.
*/
protected void snooze(NotificationRecord record, long duration) {
String key = record.getKey();
snooze(record);
scheduleRepost(key, duration);
Long activateAt = System.currentTimeMillis() + duration;
synchronized (mLock) {
mPersistedSnoozedNotifications.put(getTrimmedString(key), activateAt);
}
}
/**
* Records a snoozed notification.
*/
protected void snooze(NotificationRecord record, String contextId) {
if (contextId != null) {
synchronized (mLock) {
mPersistedSnoozedNotificationsWithContext.put(
getTrimmedString(record.getKey()),
getTrimmedString(contextId)
);
}
}
snooze(record);
}
private void snooze(NotificationRecord record) {
if (DEBUG) {
Slog.d(TAG, "Snoozing " + record.getKey());
}
synchronized (mLock) {
mSnoozedNotifications.put(record.getKey(), record);
}
}
private String getTrimmedString(String key) {
if (key != null && key.length() > MAX_STRING_LENGTH) {
return key.substring(0, MAX_STRING_LENGTH);
}
return key;
}
protected boolean cancel(int userId, String pkg, String tag, int id) {
synchronized (mLock) {
final Set<Map.Entry<String, NotificationRecord>> records =
mSnoozedNotifications.entrySet();
for (Map.Entry<String, NotificationRecord> record : records) {
final StatusBarNotification sbn = record.getValue().getSbn();
if (sbn.getPackageName().equals(pkg) && sbn.getUserId() == userId
&& Objects.equals(sbn.getTag(), tag) && sbn.getId() == id) {
record.getValue().isCanceled = true;
return true;
}
}
}
return false;
}
protected void cancel(int userId, boolean includeCurrentProfiles) {
synchronized (mLock) {
if (mSnoozedNotifications.size() == 0) {
return;
}
IntArray userIds = new IntArray();
userIds.add(userId);
if (includeCurrentProfiles) {
userIds = mUserProfiles.getCurrentProfileIds();
}
for (NotificationRecord r : mSnoozedNotifications.values()) {
if (userIds.binarySearch(r.getUserId()) >= 0) {
r.isCanceled = true;
}
}
}
}
protected boolean cancel(int userId, String pkg) {
synchronized (mLock) {
int n = mSnoozedNotifications.size();
for (int i = 0; i < n; i++) {
final NotificationRecord r = mSnoozedNotifications.valueAt(i);
if (r.getSbn().getPackageName().equals(pkg) && r.getUserId() == userId) {
r.isCanceled = true;
}
}
return true;
}
}
/**
* Updates the notification record so the most up to date information is shown on re-post.
*/
protected void update(int userId, NotificationRecord record) {
synchronized (mLock) {
if (mSnoozedNotifications.containsKey(record.getKey())) {
mSnoozedNotifications.put(record.getKey(), record);
}
}
}
protected void repost(String key, boolean muteOnReturn) {
synchronized (mLock) {
final NotificationRecord r = mSnoozedNotifications.get(key);
if (r != null) {
repost(key, r.getUserId(), muteOnReturn);
}
}
}
protected void repost(String key, int userId, boolean muteOnReturn) {
final String trimmedKey = getTrimmedString(key);
NotificationRecord record;
synchronized (mLock) {
mPersistedSnoozedNotifications.remove(trimmedKey);
mPersistedSnoozedNotificationsWithContext.remove(trimmedKey);
record = mSnoozedNotifications.remove(key);
}
if (record != null && !record.isCanceled) {
final PendingIntent pi = createPendingIntent(record.getKey());
mAm.cancel(pi);
MetricsLogger.action(record.getLogMaker()
.setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
.setType(MetricsProto.MetricsEvent.TYPE_OPEN));
mCallback.repost(record.getUserId(), record, muteOnReturn);
}
}
protected void repostGroupSummary(String pkg, int userId, String groupKey) {
synchronized (mLock) {
String groupSummaryKey = null;
int n = mSnoozedNotifications.size();
for (int i = 0; i < n; i++) {
final NotificationRecord potentialGroupSummary = mSnoozedNotifications.valueAt(i);
if (potentialGroupSummary.getSbn().getPackageName().equals(pkg)
&& potentialGroupSummary.getUserId() == userId
&& potentialGroupSummary.getSbn().isGroup()
&& potentialGroupSummary.getNotification().isGroupSummary()
&& groupKey.equals(potentialGroupSummary.getGroupKey())) {
groupSummaryKey = potentialGroupSummary.getKey();
break;
}
}
if (groupSummaryKey != null) {
NotificationRecord record = mSnoozedNotifications.remove(groupSummaryKey);
if (record != null && !record.isCanceled) {
Runnable runnable = () -> {
MetricsLogger.action(record.getLogMaker()
.setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
.setType(MetricsProto.MetricsEvent.TYPE_OPEN));
mCallback.repost(record.getUserId(), record, false);
};
runnable.run();
}
}
}
}
protected void clearData(int userId, String pkg) {
synchronized (mLock) {
int n = mSnoozedNotifications.size();
for (int i = n - 1; i >= 0; i--) {
final NotificationRecord record = mSnoozedNotifications.valueAt(i);
if (record.getUserId() == userId && record.getSbn().getPackageName().equals(pkg)) {
mSnoozedNotifications.removeAt(i);
String trimmedKey = getTrimmedString(record.getKey());
mPersistedSnoozedNotificationsWithContext.remove(trimmedKey);
mPersistedSnoozedNotifications.remove(trimmedKey);
Runnable runnable = () -> {
final PendingIntent pi = createPendingIntent(record.getKey());
mAm.cancel(pi);
MetricsLogger.action(record.getLogMaker()
.setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
.setType(MetricsProto.MetricsEvent.TYPE_DISMISS));
};
runnable.run();
}
}
}
}
protected void clearData(int userId) {
synchronized (mLock) {
int n = mSnoozedNotifications.size();
for (int i = n - 1; i >= 0; i--) {
final NotificationRecord record = mSnoozedNotifications.valueAt(i);
if (record.getUserId() == userId) {
mSnoozedNotifications.removeAt(i);
String trimmedKey = getTrimmedString(record.getKey());
mPersistedSnoozedNotificationsWithContext.remove(trimmedKey);
mPersistedSnoozedNotifications.remove(trimmedKey);
Runnable runnable = () -> {
final PendingIntent pi = createPendingIntent(record.getKey());
mAm.cancel(pi);
MetricsLogger.action(record.getLogMaker()
.setCategory(MetricsProto.MetricsEvent.NOTIFICATION_SNOOZED)
.setType(MetricsProto.MetricsEvent.TYPE_DISMISS));
};
runnable.run();
}
}
}
}
private PendingIntent createPendingIntent(String key) {
return PendingIntent.getBroadcast(mContext,
REQUEST_CODE_REPOST,
new Intent(REPOST_ACTION)
.setPackage(PackageManagerService.PLATFORM_PACKAGE_NAME)
.setData(new Uri.Builder().scheme(REPOST_SCHEME).appendPath(key).build())
.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
.putExtra(EXTRA_KEY, key),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
public void scheduleRepostsForPersistedNotifications(long currentTime) {
synchronized (mLock) {
for (int i = 0; i < mPersistedSnoozedNotifications.size(); i++) {
String key = mPersistedSnoozedNotifications.keyAt(i);
Long time = mPersistedSnoozedNotifications.valueAt(i);
if (time != null && time > currentTime) {
scheduleRepostAtTime(key, time);
}
}
}
}
private void scheduleRepost(String key, long duration) {
scheduleRepostAtTime(key, System.currentTimeMillis() + duration);
}
private void scheduleRepostAtTime(String key, long time) {
Runnable runnable = () -> {
final long identity = Binder.clearCallingIdentity();
try {
final PendingIntent pi = createPendingIntent(key);
mAm.cancel(pi);
if (DEBUG) Slog.d(TAG, "Scheduling evaluate for " + new Date(time));
mAm.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pi);
} finally {
Binder.restoreCallingIdentity(identity);
}
};
runnable.run();
}
public void dump(PrintWriter pw, NotificationManagerService.DumpFilter filter) {
synchronized (mLock) {
pw.println("\n Snoozed notifications:");
for (String key : mSnoozedNotifications.keySet()) {
pw.print(INDENT);
pw.println("key: " + key);
}
pw.println("\n Pending snoozed notifications");
for (String key : mPersistedSnoozedNotifications.keySet()) {
pw.print(INDENT);
pw.println("key: " + key + " until: " + mPersistedSnoozedNotifications.get(key));
}
}
}
protected void writeXml(TypedXmlSerializer out) throws IOException {
synchronized (mLock) {
final long currentTime = System.currentTimeMillis();
out.startTag(null, XML_TAG_NAME);
writeXml(out, mPersistedSnoozedNotifications, XML_SNOOZED_NOTIFICATION,
value -> {
if (value < currentTime) {
return;
}
out.attributeLong(null, XML_SNOOZED_NOTIFICATION_TIME,
value);
});
writeXml(out, mPersistedSnoozedNotificationsWithContext,
XML_SNOOZED_NOTIFICATION_CONTEXT,
value -> {
out.attribute(null, XML_SNOOZED_NOTIFICATION_CONTEXT_ID,
value);
});
out.endTag(null, XML_TAG_NAME);
}
}
private interface Inserter<T> {
void insert(T t) throws IOException;
}
private <T> void writeXml(TypedXmlSerializer out, ArrayMap<String, T> targets, String tag,
Inserter<T> attributeInserter) throws IOException {
for (int j = 0; j < targets.size(); j++) {
String key = targets.keyAt(j);
// T is a String (snoozed until context) or Long (snoozed until time)
T value = targets.valueAt(j);
out.startTag(null, tag);
attributeInserter.insert(value);
out.attributeInt(null, XML_SNOOZED_NOTIFICATION_VERSION_LABEL,
XML_SNOOZED_NOTIFICATION_VERSION);
out.attribute(null, XML_SNOOZED_NOTIFICATION_KEY, key);
out.endTag(null, tag);
}
}
protected void readXml(TypedXmlPullParser parser, long currentTime)
throws XmlPullParserException, IOException {
int type;
while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
String tag = parser.getName();
if (type == XmlPullParser.END_TAG
&& XML_TAG_NAME.equals(tag)) {
break;
}
if (type == XmlPullParser.START_TAG
&& (XML_SNOOZED_NOTIFICATION.equals(tag)
|| tag.equals(XML_SNOOZED_NOTIFICATION_CONTEXT))
&& parser.getAttributeInt(null, XML_SNOOZED_NOTIFICATION_VERSION_LABEL, -1)
== XML_SNOOZED_NOTIFICATION_VERSION) {
try {
final String key = parser.getAttributeValue(null, XML_SNOOZED_NOTIFICATION_KEY);
if (tag.equals(XML_SNOOZED_NOTIFICATION)) {
final Long time = parser.getAttributeLong(
null, XML_SNOOZED_NOTIFICATION_TIME, 0);
if (time > currentTime) { //only read new stuff
synchronized (mLock) {
mPersistedSnoozedNotifications.put(key, time);
}
}
}
if (tag.equals(XML_SNOOZED_NOTIFICATION_CONTEXT)) {
final String creationId = parser.getAttributeValue(
null, XML_SNOOZED_NOTIFICATION_CONTEXT_ID);
synchronized (mLock) {
mPersistedSnoozedNotificationsWithContext.put(key, creationId);
}
}
} catch (Exception e) {
Slog.e(TAG, "Exception in reading snooze data from policy xml", e);
}
}
}
}
@VisibleForTesting
void setAlarmManager(AlarmManager am) {
mAm = am;
}
protected interface Callback {
void repost(int userId, NotificationRecord r, boolean muteOnReturn);
}
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_KEY), intent.getIntExtra(EXTRA_USER_ID,
UserHandle.USER_SYSTEM), false);
}
}
};
}