1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.launcher3.notification;
18 
19 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
20 import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
21 import static com.android.launcher3.util.SettingsCache.NOTIFICATION_BADGING_URI;
22 
23 import android.annotation.TargetApi;
24 import android.app.Notification;
25 import android.app.NotificationChannel;
26 import android.os.Build;
27 import android.os.Handler;
28 import android.os.Looper;
29 import android.os.Message;
30 import android.service.notification.NotificationListenerService;
31 import android.service.notification.StatusBarNotification;
32 import android.text.TextUtils;
33 import android.util.ArraySet;
34 import android.util.Log;
35 import android.util.Pair;
36 
37 import androidx.annotation.NonNull;
38 import androidx.annotation.Nullable;
39 import androidx.annotation.WorkerThread;
40 
41 import com.android.launcher3.util.PackageUserKey;
42 import com.android.launcher3.util.SettingsCache;
43 
44 import java.util.ArrayList;
45 import java.util.Arrays;
46 import java.util.Collections;
47 import java.util.HashMap;
48 import java.util.List;
49 import java.util.Map;
50 import java.util.stream.Collectors;
51 
52 /**
53  * A {@link NotificationListenerService} that sends updates to its
54  * {@link NotificationsChangedListener} when notifications are posted or canceled,
55  * as well and when this service first connects. An instance of NotificationListener,
56  * and its methods for getting notifications, can be obtained via {@link #getInstanceIfConnected()}.
57  */
58 @TargetApi(Build.VERSION_CODES.O)
59 public class NotificationListener extends NotificationListenerService {
60 
61     public static final String TAG = "NotificationListener";
62 
63     private static final int MSG_NOTIFICATION_POSTED = 1;
64     private static final int MSG_NOTIFICATION_REMOVED = 2;
65     private static final int MSG_NOTIFICATION_FULL_REFRESH = 3;
66     private static final int MSG_RANKING_UPDATE = 4;
67 
68     private static NotificationListener sNotificationListenerInstance = null;
69     private static final ArraySet<NotificationsChangedListener> sNotificationsChangedListeners =
70             new ArraySet<>();
71     private static boolean sIsConnected;
72 
73     private final Handler mWorkerHandler;
74     private final Handler mUiHandler;
75     private final Ranking mTempRanking = new Ranking();
76 
77     /** Maps groupKey's to the corresponding group of notifications. */
78     private final Map<String, NotificationGroup> mNotificationGroupMap = new HashMap<>();
79     /** Maps keys to their corresponding current group key */
80     private final Map<String, String> mNotificationGroupKeyMap = new HashMap<>();
81 
82     private SettingsCache mSettingsCache;
83     private SettingsCache.OnChangeListener mNotificationSettingsChangedListener;
84 
NotificationListener()85     public NotificationListener() {
86         mWorkerHandler = new Handler(MODEL_EXECUTOR.getLooper(), this::handleWorkerMessage);
87         mUiHandler = new Handler(Looper.getMainLooper(), this::handleUiMessage);
88         sNotificationListenerInstance = this;
89     }
90 
getInstanceIfConnected()91     private static @Nullable NotificationListener getInstanceIfConnected() {
92         return sIsConnected ? sNotificationListenerInstance : null;
93     }
94 
addNotificationsChangedListener(NotificationsChangedListener listener)95     public static void addNotificationsChangedListener(NotificationsChangedListener listener) {
96         if (listener == null) {
97             return;
98         }
99         sNotificationsChangedListeners.add(listener);
100 
101         NotificationListener notificationListener = getInstanceIfConnected();
102         if (notificationListener != null) {
103             notificationListener.onNotificationFullRefresh();
104         } else {
105             // User turned off dots globally, so we unbound this service;
106             // tell the listener that there are no notifications to remove dots.
107             MODEL_EXECUTOR.submit(() -> MAIN_EXECUTOR.submit(() ->
108                             listener.onNotificationFullRefresh(Collections.emptyList())));
109         }
110     }
111 
removeNotificationsChangedListener(NotificationsChangedListener listener)112     public static void removeNotificationsChangedListener(NotificationsChangedListener listener) {
113         if (listener != null) {
114             sNotificationsChangedListeners.remove(listener);
115         }
116     }
117 
handleWorkerMessage(Message message)118     private boolean handleWorkerMessage(Message message) {
119         switch (message.what) {
120             case MSG_NOTIFICATION_POSTED: {
121                 StatusBarNotification sbn = (StatusBarNotification) message.obj;
122                 mUiHandler.obtainMessage(notificationIsValidForUI(sbn)
123                                 ? MSG_NOTIFICATION_POSTED : MSG_NOTIFICATION_REMOVED,
124                         toKeyPair(sbn)).sendToTarget();
125                 return true;
126             }
127             case MSG_NOTIFICATION_REMOVED: {
128                 StatusBarNotification sbn = (StatusBarNotification) message.obj;
129                 mUiHandler.obtainMessage(MSG_NOTIFICATION_REMOVED,
130                         toKeyPair(sbn)).sendToTarget();
131 
132                 NotificationGroup notificationGroup = mNotificationGroupMap.get(sbn.getGroupKey());
133                 String key = sbn.getKey();
134                 if (notificationGroup != null) {
135                     notificationGroup.removeChildKey(key);
136                     if (notificationGroup.isEmpty()) {
137                         mNotificationGroupMap.remove(sbn.getGroupKey());
138                     }
139                 }
140                 return true;
141             }
142             case MSG_NOTIFICATION_FULL_REFRESH:
143                 List<StatusBarNotification> activeNotifications = null;
144                 if (sIsConnected) {
145                     activeNotifications = Arrays.stream(getActiveNotificationsSafely(null))
146                             .filter(this::notificationIsValidForUI)
147                             .collect(Collectors.toList());
148                 } else {
149                     activeNotifications = new ArrayList<>();
150                 }
151 
152                 mUiHandler.obtainMessage(message.what, activeNotifications).sendToTarget();
153                 return true;
154             case MSG_RANKING_UPDATE: {
155                 String[] keys = ((RankingMap) message.obj).getOrderedKeys();
156                 for (StatusBarNotification sbn : getActiveNotificationsSafely(keys)) {
157                     updateGroupKeyIfNecessary(sbn);
158                 }
159                 return true;
160             }
161         }
162         return false;
163     }
164 
handleUiMessage(Message message)165     private boolean handleUiMessage(Message message) {
166         switch (message.what) {
167             case MSG_NOTIFICATION_POSTED:
168                 if (sNotificationsChangedListeners.size() > 0) {
169                     Pair<PackageUserKey, NotificationKeyData> msg = (Pair) message.obj;
170                     for (NotificationsChangedListener listener : sNotificationsChangedListeners) {
171                         listener.onNotificationPosted(msg.first, msg.second);
172                     }
173                 }
174                 break;
175             case MSG_NOTIFICATION_REMOVED:
176                 if (sNotificationsChangedListeners.size() > 0) {
177                     Pair<PackageUserKey, NotificationKeyData> msg = (Pair) message.obj;
178                     for (NotificationsChangedListener listener : sNotificationsChangedListeners) {
179                         listener.onNotificationRemoved(msg.first, msg.second);
180                     }
181                 }
182                 break;
183             case MSG_NOTIFICATION_FULL_REFRESH:
184                 if (sNotificationsChangedListeners.size() > 0) {
185                     for (NotificationsChangedListener listener : sNotificationsChangedListeners) {
186                         listener.onNotificationFullRefresh(
187                                 (List<StatusBarNotification>) message.obj);
188                     }
189                 }
190                 break;
191         }
192         return true;
193     }
194 
getActiveNotificationsSafely(@ullable String[] keys)195     private @NonNull StatusBarNotification[] getActiveNotificationsSafely(@Nullable String[] keys) {
196         StatusBarNotification[] result = null;
197         try {
198             result = getActiveNotifications(keys);
199         } catch (SecurityException e) {
200             Log.e(TAG, "SecurityException: failed to fetch notifications");
201         }
202         return result == null ? new StatusBarNotification[0] : result;
203     }
204 
205     @Override
onListenerConnected()206     public void onListenerConnected() {
207         super.onListenerConnected();
208         sIsConnected = true;
209 
210         // Register an observer to rebind the notification listener when dots are re-enabled.
211         mSettingsCache = SettingsCache.INSTANCE.get(this);
212         mNotificationSettingsChangedListener = this::onNotificationSettingsChanged;
213         mSettingsCache.register(NOTIFICATION_BADGING_URI,
214                 mNotificationSettingsChangedListener);
215         onNotificationSettingsChanged(mSettingsCache.getValue(NOTIFICATION_BADGING_URI));
216 
217         onNotificationFullRefresh();
218     }
219 
onNotificationSettingsChanged(boolean areNotificationDotsEnabled)220     private void onNotificationSettingsChanged(boolean areNotificationDotsEnabled) {
221         if (!areNotificationDotsEnabled && sIsConnected) {
222             requestUnbind();
223         }
224     }
225 
onNotificationFullRefresh()226     private void onNotificationFullRefresh() {
227         mWorkerHandler.obtainMessage(MSG_NOTIFICATION_FULL_REFRESH).sendToTarget();
228     }
229 
230     @Override
onListenerDisconnected()231     public void onListenerDisconnected() {
232         super.onListenerDisconnected();
233         sIsConnected = false;
234         mSettingsCache.unregister(NOTIFICATION_BADGING_URI, mNotificationSettingsChangedListener);
235         onNotificationFullRefresh();
236     }
237 
238     @Override
onNotificationPosted(final StatusBarNotification sbn)239     public void onNotificationPosted(final StatusBarNotification sbn) {
240         if (sbn != null) {
241             mWorkerHandler.obtainMessage(MSG_NOTIFICATION_POSTED, sbn).sendToTarget();
242         }
243     }
244 
245     @Override
onNotificationRemoved(final StatusBarNotification sbn)246     public void onNotificationRemoved(final StatusBarNotification sbn) {
247         if (sbn != null) {
248             mWorkerHandler.obtainMessage(MSG_NOTIFICATION_REMOVED, sbn).sendToTarget();
249         }
250     }
251 
252     @Override
onNotificationRankingUpdate(RankingMap rankingMap)253     public void onNotificationRankingUpdate(RankingMap rankingMap) {
254         mWorkerHandler.obtainMessage(MSG_RANKING_UPDATE, rankingMap).sendToTarget();
255     }
256 
257     @WorkerThread
updateGroupKeyIfNecessary(StatusBarNotification sbn)258     private void updateGroupKeyIfNecessary(StatusBarNotification sbn) {
259         String childKey = sbn.getKey();
260         String oldGroupKey = mNotificationGroupKeyMap.get(childKey);
261         String newGroupKey = sbn.getGroupKey();
262         if (oldGroupKey == null || !oldGroupKey.equals(newGroupKey)) {
263             // The group key has changed.
264             mNotificationGroupKeyMap.put(childKey, newGroupKey);
265             if (oldGroupKey != null && mNotificationGroupMap.containsKey(oldGroupKey)) {
266                 // Remove the child key from the old group.
267                 NotificationGroup oldGroup = mNotificationGroupMap.get(oldGroupKey);
268                 oldGroup.removeChildKey(childKey);
269                 if (oldGroup.isEmpty()) {
270                     mNotificationGroupMap.remove(oldGroupKey);
271                 }
272             }
273         }
274         if (sbn.isGroup() && newGroupKey != null) {
275             // Maintain group info so we can cancel the summary when the last child is canceled.
276             NotificationGroup notificationGroup = mNotificationGroupMap.get(newGroupKey);
277             if (notificationGroup == null) {
278                 notificationGroup = new NotificationGroup();
279                 mNotificationGroupMap.put(newGroupKey, notificationGroup);
280             }
281             boolean isGroupSummary = (sbn.getNotification().flags
282                     & Notification.FLAG_GROUP_SUMMARY) != 0;
283             if (isGroupSummary) {
284                 notificationGroup.setGroupSummaryKey(childKey);
285             } else {
286                 notificationGroup.addChildKey(childKey);
287             }
288         }
289     }
290 
291     /**
292      * Returns true for notifications that have an intent and are not headers for grouped
293      * notifications and should be shown in the notification popup.
294      */
295     @WorkerThread
notificationIsValidForUI(StatusBarNotification sbn)296     private boolean notificationIsValidForUI(StatusBarNotification sbn) {
297         Notification notification = sbn.getNotification();
298         updateGroupKeyIfNecessary(sbn);
299 
300         getCurrentRanking().getRanking(sbn.getKey(), mTempRanking);
301         if (!mTempRanking.canShowBadge()) {
302             return false;
303         }
304         if (mTempRanking.getChannel().getId().equals(NotificationChannel.DEFAULT_CHANNEL_ID)) {
305             // Special filtering for the default, legacy "Miscellaneous" channel.
306             if ((notification.flags & Notification.FLAG_ONGOING_EVENT) != 0) {
307                 return false;
308             }
309         }
310 
311         CharSequence title = notification.extras.getCharSequence(Notification.EXTRA_TITLE);
312         CharSequence text = notification.extras.getCharSequence(Notification.EXTRA_TEXT);
313         boolean missingTitleAndText = TextUtils.isEmpty(title) && TextUtils.isEmpty(text);
314         boolean isGroupHeader = (notification.flags & Notification.FLAG_GROUP_SUMMARY) != 0;
315         return !isGroupHeader && !missingTitleAndText;
316     }
317 
toKeyPair(StatusBarNotification sbn)318     private static Pair<PackageUserKey, NotificationKeyData> toKeyPair(StatusBarNotification sbn) {
319         return Pair.create(PackageUserKey.fromNotification(sbn),
320                 NotificationKeyData.fromNotification(sbn));
321     }
322 
323     public interface NotificationsChangedListener {
onNotificationPosted(PackageUserKey postedPackageUserKey, NotificationKeyData notificationKey)324         void onNotificationPosted(PackageUserKey postedPackageUserKey,
325                 NotificationKeyData notificationKey);
onNotificationRemoved(PackageUserKey removedPackageUserKey, NotificationKeyData notificationKey)326         void onNotificationRemoved(PackageUserKey removedPackageUserKey,
327                 NotificationKeyData notificationKey);
onNotificationFullRefresh(List<StatusBarNotification> activeNotifications)328         void onNotificationFullRefresh(List<StatusBarNotification> activeNotifications);
329     }
330 }
331