blob: e58f5fa2d58aa5a7bccd78f344f1167c09dd5036 [file] [log] [blame]
/*
* Copyright (C) 2017 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.launcher3.notification;
import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
import static com.android.launcher3.util.SettingsCache.NOTIFICATION_BADGING_URI;
import android.annotation.TargetApi;
import android.app.Notification;
import android.app.NotificationChannel;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.service.notification.NotificationListenerService;
import android.service.notification.StatusBarNotification;
import android.text.TextUtils;
import android.util.Log;
import android.util.Pair;
import androidx.annotation.AnyThread;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.android.launcher3.util.PackageUserKey;
import com.android.launcher3.util.SettingsCache;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* A {@link NotificationListenerService} that sends updates to its
* {@link NotificationsChangedListener} when notifications are posted or canceled,
* as well and when this service first connects. An instance of NotificationListener,
* and its methods for getting notifications, can be obtained via {@link #getInstanceIfConnected()}.
*/
@TargetApi(Build.VERSION_CODES.O)
public class NotificationListener extends NotificationListenerService {
public static final String TAG = "NotificationListener";
private static final int MSG_NOTIFICATION_POSTED = 1;
private static final int MSG_NOTIFICATION_REMOVED = 2;
private static final int MSG_NOTIFICATION_FULL_REFRESH = 3;
private static final int MSG_CANCEL_NOTIFICATION = 4;
private static final int MSG_RANKING_UPDATE = 5;
private static NotificationListener sNotificationListenerInstance = null;
private static NotificationsChangedListener sNotificationsChangedListener;
private static boolean sIsConnected;
private final Handler mWorkerHandler;
private final Handler mUiHandler;
private final Ranking mTempRanking = new Ranking();
/** Maps groupKey's to the corresponding group of notifications. */
private final Map<String, NotificationGroup> mNotificationGroupMap = new HashMap<>();
/** Maps keys to their corresponding current group key */
private final Map<String, String> mNotificationGroupKeyMap = new HashMap<>();
/** The last notification key that was dismissed from launcher UI */
private String mLastKeyDismissedByLauncher;
private SettingsCache mSettingsCache;
private SettingsCache.OnChangeListener mNotificationSettingsChangedListener;
public NotificationListener() {
mWorkerHandler = new Handler(MODEL_EXECUTOR.getLooper(), this::handleWorkerMessage);
mUiHandler = new Handler(Looper.getMainLooper(), this::handleUiMessage);
sNotificationListenerInstance = this;
}
public static @Nullable NotificationListener getInstanceIfConnected() {
return sIsConnected ? sNotificationListenerInstance : null;
}
public static void setNotificationsChangedListener(NotificationsChangedListener listener) {
sNotificationsChangedListener = listener;
NotificationListener notificationListener = getInstanceIfConnected();
if (notificationListener != null) {
notificationListener.onNotificationFullRefresh();
} else {
// User turned off dots globally, so we unbound this service;
// tell the listener that there are no notifications to remove dots.
MODEL_EXECUTOR.submit(() -> MAIN_EXECUTOR.submit(() ->
listener.onNotificationFullRefresh(Collections.emptyList())));
}
}
public static void removeNotificationsChangedListener() {
sNotificationsChangedListener = null;
}
private boolean handleWorkerMessage(Message message) {
switch (message.what) {
case MSG_NOTIFICATION_POSTED: {
StatusBarNotification sbn = (StatusBarNotification) message.obj;
mUiHandler.obtainMessage(notificationIsValidForUI(sbn)
? MSG_NOTIFICATION_POSTED : MSG_NOTIFICATION_REMOVED,
toKeyPair(sbn)).sendToTarget();
return true;
}
case MSG_NOTIFICATION_REMOVED: {
StatusBarNotification sbn = (StatusBarNotification) message.obj;
mUiHandler.obtainMessage(MSG_NOTIFICATION_REMOVED,
toKeyPair(sbn)).sendToTarget();
NotificationGroup notificationGroup = mNotificationGroupMap.get(sbn.getGroupKey());
String key = sbn.getKey();
if (notificationGroup != null) {
notificationGroup.removeChildKey(key);
if (notificationGroup.isEmpty()) {
if (key.equals(mLastKeyDismissedByLauncher)) {
// Only cancel the group notification if launcher dismissed the
// last child.
cancelNotification(notificationGroup.getGroupSummaryKey());
}
mNotificationGroupMap.remove(sbn.getGroupKey());
}
}
if (key.equals(mLastKeyDismissedByLauncher)) {
mLastKeyDismissedByLauncher = null;
}
return true;
}
case MSG_NOTIFICATION_FULL_REFRESH:
List<StatusBarNotification> activeNotifications = null;
if (sIsConnected) {
try {
activeNotifications = Arrays.stream(getActiveNotifications())
.filter(this::notificationIsValidForUI)
.collect(Collectors.toList());
} catch (SecurityException ex) {
Log.e(TAG, "SecurityException: failed to fetch notifications");
activeNotifications = new ArrayList<>();
}
} else {
activeNotifications = new ArrayList<>();
}
mUiHandler.obtainMessage(message.what, activeNotifications).sendToTarget();
return true;
case MSG_CANCEL_NOTIFICATION: {
mLastKeyDismissedByLauncher = (String) message.obj;
cancelNotification(mLastKeyDismissedByLauncher);
return true;
}
case MSG_RANKING_UPDATE: {
String[] keys = ((RankingMap) message.obj).getOrderedKeys();
for (StatusBarNotification sbn : getActiveNotifications(keys)) {
updateGroupKeyIfNecessary(sbn);
}
return true;
}
}
return false;
}
private boolean handleUiMessage(Message message) {
switch (message.what) {
case MSG_NOTIFICATION_POSTED:
if (sNotificationsChangedListener != null) {
Pair<PackageUserKey, NotificationKeyData> msg = (Pair) message.obj;
sNotificationsChangedListener.onNotificationPosted(
msg.first, msg.second);
}
break;
case MSG_NOTIFICATION_REMOVED:
if (sNotificationsChangedListener != null) {
Pair<PackageUserKey, NotificationKeyData> msg = (Pair) message.obj;
sNotificationsChangedListener.onNotificationRemoved(
msg.first, msg.second);
}
break;
case MSG_NOTIFICATION_FULL_REFRESH:
if (sNotificationsChangedListener != null) {
sNotificationsChangedListener.onNotificationFullRefresh(
(List<StatusBarNotification>) message.obj);
}
break;
}
return true;
}
@Override
public void onListenerConnected() {
super.onListenerConnected();
sIsConnected = true;
// Register an observer to rebind the notification listener when dots are re-enabled.
mSettingsCache = SettingsCache.INSTANCE.get(this);
mNotificationSettingsChangedListener = this::onNotificationSettingsChanged;
mSettingsCache.register(NOTIFICATION_BADGING_URI,
mNotificationSettingsChangedListener);
onNotificationSettingsChanged(mSettingsCache.getValue(NOTIFICATION_BADGING_URI));
onNotificationFullRefresh();
}
private void onNotificationSettingsChanged(boolean areNotificationDotsEnabled) {
if (!areNotificationDotsEnabled && sIsConnected) {
requestUnbind();
}
}
private void onNotificationFullRefresh() {
mWorkerHandler.obtainMessage(MSG_NOTIFICATION_FULL_REFRESH).sendToTarget();
}
@Override
public void onListenerDisconnected() {
super.onListenerDisconnected();
sIsConnected = false;
mSettingsCache.unregister(NOTIFICATION_BADGING_URI, mNotificationSettingsChangedListener);
onNotificationFullRefresh();
}
@Override
public void onNotificationPosted(final StatusBarNotification sbn) {
if (sbn != null) {
mWorkerHandler.obtainMessage(MSG_NOTIFICATION_POSTED, sbn).sendToTarget();
}
}
@Override
public void onNotificationRemoved(final StatusBarNotification sbn) {
if (sbn != null) {
mWorkerHandler.obtainMessage(MSG_NOTIFICATION_REMOVED, sbn).sendToTarget();
}
}
@Override
public void onNotificationRankingUpdate(RankingMap rankingMap) {
mWorkerHandler.obtainMessage(MSG_RANKING_UPDATE, rankingMap).sendToTarget();
}
/**
* Cancels a notification
*/
@AnyThread
public void cancelNotificationFromLauncher(String key) {
mWorkerHandler.obtainMessage(MSG_CANCEL_NOTIFICATION, key).sendToTarget();
}
@WorkerThread
private void updateGroupKeyIfNecessary(StatusBarNotification sbn) {
String childKey = sbn.getKey();
String oldGroupKey = mNotificationGroupKeyMap.get(childKey);
String newGroupKey = sbn.getGroupKey();
if (oldGroupKey == null || !oldGroupKey.equals(newGroupKey)) {
// The group key has changed.
mNotificationGroupKeyMap.put(childKey, newGroupKey);
if (oldGroupKey != null && mNotificationGroupMap.containsKey(oldGroupKey)) {
// Remove the child key from the old group.
NotificationGroup oldGroup = mNotificationGroupMap.get(oldGroupKey);
oldGroup.removeChildKey(childKey);
if (oldGroup.isEmpty()) {
mNotificationGroupMap.remove(oldGroupKey);
}
}
}
if (sbn.isGroup() && newGroupKey != null) {
// Maintain group info so we can cancel the summary when the last child is canceled.
NotificationGroup notificationGroup = mNotificationGroupMap.get(newGroupKey);
if (notificationGroup == null) {
notificationGroup = new NotificationGroup();
mNotificationGroupMap.put(newGroupKey, notificationGroup);
}
boolean isGroupSummary = (sbn.getNotification().flags
& Notification.FLAG_GROUP_SUMMARY) != 0;
if (isGroupSummary) {
notificationGroup.setGroupSummaryKey(childKey);
} else {
notificationGroup.addChildKey(childKey);
}
}
}
/**
* This makes a potentially expensive binder call and should be run on a background thread.
*/
@WorkerThread
public List<StatusBarNotification> getNotificationsForKeys(List<NotificationKeyData> keys) {
StatusBarNotification[] notifications = getActiveNotifications(
keys.stream().map(n -> n.notificationKey).toArray(String[]::new));
return notifications == null ? Collections.emptyList() : Arrays.asList(notifications);
}
/**
* Returns true for notifications that have an intent and are not headers for grouped
* notifications and should be shown in the notification popup.
*/
@WorkerThread
private boolean notificationIsValidForUI(StatusBarNotification sbn) {
Notification notification = sbn.getNotification();
updateGroupKeyIfNecessary(sbn);
getCurrentRanking().getRanking(sbn.getKey(), mTempRanking);
if (!mTempRanking.canShowBadge()) {
return false;
}
if (mTempRanking.getChannel().getId().equals(NotificationChannel.DEFAULT_CHANNEL_ID)) {
// Special filtering for the default, legacy "Miscellaneous" channel.
if ((notification.flags & Notification.FLAG_ONGOING_EVENT) != 0) {
return false;
}
}
CharSequence title = notification.extras.getCharSequence(Notification.EXTRA_TITLE);
CharSequence text = notification.extras.getCharSequence(Notification.EXTRA_TEXT);
boolean missingTitleAndText = TextUtils.isEmpty(title) && TextUtils.isEmpty(text);
boolean isGroupHeader = (notification.flags & Notification.FLAG_GROUP_SUMMARY) != 0;
return !isGroupHeader && !missingTitleAndText;
}
private static Pair<PackageUserKey, NotificationKeyData> toKeyPair(StatusBarNotification sbn) {
return Pair.create(PackageUserKey.fromNotification(sbn),
NotificationKeyData.fromNotification(sbn));
}
public interface NotificationsChangedListener {
void onNotificationPosted(PackageUserKey postedPackageUserKey,
NotificationKeyData notificationKey);
void onNotificationRemoved(PackageUserKey removedPackageUserKey,
NotificationKeyData notificationKey);
void onNotificationFullRefresh(List<StatusBarNotification> activeNotifications);
}
}