1 /*
2  * Copyright (C) 2016 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 package com.android.server.notification;
17 
18 import static android.app.Notification.COLOR_DEFAULT;
19 import static android.app.Notification.FLAG_AUTOGROUP_SUMMARY;
20 import static android.app.Notification.FLAG_AUTO_CANCEL;
21 import static android.app.Notification.FLAG_GROUP_SUMMARY;
22 import static android.app.Notification.FLAG_LOCAL_ONLY;
23 import static android.app.Notification.FLAG_NO_CLEAR;
24 import static android.app.Notification.FLAG_ONGOING_EVENT;
25 import static android.app.Notification.VISIBILITY_PRIVATE;
26 import static android.app.Notification.VISIBILITY_PUBLIC;
27 
28 import android.annotation.NonNull;
29 import android.app.Notification;
30 import android.content.Context;
31 import android.content.pm.PackageManager;
32 import android.content.pm.PackageManager.NameNotFoundException;
33 import android.graphics.drawable.AdaptiveIconDrawable;
34 import android.graphics.drawable.Drawable;
35 import android.graphics.drawable.Icon;
36 import android.service.notification.StatusBarNotification;
37 import android.util.ArrayMap;
38 import android.util.Slog;
39 
40 import com.android.internal.R;
41 import com.android.internal.annotations.GuardedBy;
42 import com.android.internal.annotations.VisibleForTesting;
43 
44 import java.util.ArrayList;
45 import java.util.List;
46 import java.util.Objects;
47 
48 /**
49  * NotificationManagerService helper for auto-grouping notifications.
50  */
51 public class GroupHelper {
52     private static final String TAG = "GroupHelper";
53 
54     protected static final String AUTOGROUP_KEY = "ranker_group";
55 
56     protected static final int FLAG_INVALID = -1;
57 
58     // Flags that all autogroup summaries have
59     protected static final int BASE_FLAGS =
60             FLAG_AUTOGROUP_SUMMARY | FLAG_GROUP_SUMMARY | FLAG_LOCAL_ONLY;
61     // Flag that autogroup summaries inherits if all children have the flag
62     private static final int ALL_CHILDREN_FLAG = FLAG_AUTO_CANCEL;
63     // Flags that autogroup summaries inherits if any child has them
64     private static final int ANY_CHILDREN_FLAGS = FLAG_ONGOING_EVENT | FLAG_NO_CLEAR;
65 
66     private final Callback mCallback;
67     private final int mAutoGroupAtCount;
68     private final Context mContext;
69     private final PackageManager mPackageManager;
70 
71     // Only contains notifications that are not explicitly grouped by the app (aka no group or
72     // sort key).
73     // userId|packageName -> (keys of notifications that aren't in an explicit app group -> flags)
74     @GuardedBy("mUngroupedNotifications")
75     private final ArrayMap<String, ArrayMap<String, NotificationAttributes>> mUngroupedNotifications
76             = new ArrayMap<>();
77 
GroupHelper(Context context, PackageManager packageManager, int autoGroupAtCount, Callback callback)78     public GroupHelper(Context context, PackageManager packageManager, int autoGroupAtCount,
79             Callback callback) {
80         mAutoGroupAtCount = autoGroupAtCount;
81         mCallback =  callback;
82         mContext = context;
83         mPackageManager = packageManager;
84     }
85 
generatePackageKey(int userId, String pkg)86     private String generatePackageKey(int userId, String pkg) {
87         return userId + "|" + pkg;
88     }
89 
90     @VisibleForTesting
91     @GuardedBy("mUngroupedNotifications")
getAutogroupSummaryFlags( @onNull final ArrayMap<String, NotificationAttributes> children)92     protected int getAutogroupSummaryFlags(
93             @NonNull final ArrayMap<String, NotificationAttributes> children) {
94         boolean allChildrenHasFlag = children.size() > 0;
95         int anyChildFlagSet = 0;
96         for (int i = 0; i < children.size(); i++) {
97             if (!hasAnyFlag(children.valueAt(i).flags, ALL_CHILDREN_FLAG)) {
98                 allChildrenHasFlag = false;
99             }
100             if (hasAnyFlag(children.valueAt(i).flags, ANY_CHILDREN_FLAGS)) {
101                 anyChildFlagSet |= (children.valueAt(i).flags & ANY_CHILDREN_FLAGS);
102             }
103         }
104         return BASE_FLAGS | (allChildrenHasFlag ? ALL_CHILDREN_FLAG : 0) | anyChildFlagSet;
105     }
106 
hasAnyFlag(int flags, int mask)107     private boolean hasAnyFlag(int flags, int mask) {
108         return (flags & mask) != 0;
109     }
110 
111     /**
112      * Called when a notification is newly posted. Checks whether that notification, and all other
113      * active notifications should be grouped or ungrouped atuomatically, and returns whether.
114      * @param sbn The posted notification.
115      * @param autogroupSummaryExists Whether a summary for this notification already exists.
116      * @return Whether the provided notification should be autogrouped synchronously.
117      */
onNotificationPosted(StatusBarNotification sbn, boolean autogroupSummaryExists)118     public boolean onNotificationPosted(StatusBarNotification sbn, boolean autogroupSummaryExists) {
119         boolean sbnToBeAutogrouped = false;
120         try {
121             if (!sbn.isAppGroup()) {
122                 sbnToBeAutogrouped = maybeGroup(sbn, autogroupSummaryExists);
123             } else {
124                 maybeUngroup(sbn, false, sbn.getUserId());
125             }
126         } catch (Exception e) {
127             Slog.e(TAG, "Failure processing new notification", e);
128         }
129         return sbnToBeAutogrouped;
130     }
131 
onNotificationRemoved(StatusBarNotification sbn)132     public void onNotificationRemoved(StatusBarNotification sbn) {
133         try {
134             maybeUngroup(sbn, true, sbn.getUserId());
135         } catch (Exception e) {
136             Slog.e(TAG, "Error processing canceled notification", e);
137         }
138     }
139 
140     /**
141      * A non-app grouped notification has been added or updated
142      * Evaluate if:
143      * (a) an existing autogroup summary needs updated flags
144      * (b) a new autogroup summary needs to be added with correct flags
145      * (c) other non-app grouped children need to be moved to the autogroup
146      *
147      * And stores the list of upgrouped notifications & their flags
148      */
maybeGroup(StatusBarNotification sbn, boolean autogroupSummaryExists)149     private boolean maybeGroup(StatusBarNotification sbn, boolean autogroupSummaryExists) {
150         int flags = 0;
151         List<String> notificationsToGroup = new ArrayList<>();
152         List<NotificationAttributes> childrenAttr = new ArrayList<>();
153         // Indicates whether the provided sbn should be autogrouped by the caller.
154         boolean sbnToBeAutogrouped = false;
155         synchronized (mUngroupedNotifications) {
156             String packageKey = generatePackageKey(sbn.getUserId(), sbn.getPackageName());
157             final ArrayMap<String, NotificationAttributes> children =
158                     mUngroupedNotifications.getOrDefault(packageKey, new ArrayMap<>());
159 
160             NotificationAttributes attr = new NotificationAttributes(sbn.getNotification().flags,
161                     sbn.getNotification().getSmallIcon(), sbn.getNotification().color,
162                     sbn.getNotification().visibility);
163             children.put(sbn.getKey(), attr);
164             mUngroupedNotifications.put(packageKey, children);
165 
166             if (children.size() >= mAutoGroupAtCount || autogroupSummaryExists) {
167                 flags = getAutogroupSummaryFlags(children);
168                 notificationsToGroup.addAll(children.keySet());
169                 childrenAttr.addAll(children.values());
170             }
171         }
172         if (notificationsToGroup.size() > 0) {
173             if (autogroupSummaryExists) {
174                 NotificationAttributes attr = new NotificationAttributes(flags,
175                         sbn.getNotification().getSmallIcon(), sbn.getNotification().color,
176                         VISIBILITY_PRIVATE);
177                 if (Flags.autogroupSummaryIconUpdate()) {
178                     attr = updateAutobundledSummaryAttributes(sbn.getPackageName(), childrenAttr,
179                             attr);
180                 }
181 
182                 mCallback.updateAutogroupSummary(sbn.getUserId(), sbn.getPackageName(), attr);
183             } else {
184                 Icon summaryIcon = sbn.getNotification().getSmallIcon();
185                 int summaryIconColor = sbn.getNotification().color;
186                 int summaryVisibility = VISIBILITY_PRIVATE;
187                 if (Flags.autogroupSummaryIconUpdate()) {
188                     // Calculate the initial summary icon, icon color and visibility
189                     NotificationAttributes iconAttr = getAutobundledSummaryAttributes(
190                             sbn.getPackageName(), childrenAttr);
191                     summaryIcon = iconAttr.icon;
192                     summaryIconColor = iconAttr.iconColor;
193                     summaryVisibility = iconAttr.visibility;
194                 }
195 
196                 NotificationAttributes attr = new NotificationAttributes(flags, summaryIcon,
197                         summaryIconColor, summaryVisibility);
198                 mCallback.addAutoGroupSummary(sbn.getUserId(), sbn.getPackageName(), sbn.getKey(),
199                         attr);
200             }
201             for (String keyToGroup : notificationsToGroup) {
202                 if (android.app.Flags.checkAutogroupBeforePost()) {
203                     if (keyToGroup.equals(sbn.getKey())) {
204                         // Autogrouping for the provided notification is to be done synchronously.
205                         sbnToBeAutogrouped = true;
206                     } else {
207                         mCallback.addAutoGroup(keyToGroup, /*requestSort=*/true);
208                     }
209                 } else {
210                     mCallback.addAutoGroup(keyToGroup, /*requestSort=*/true);
211                 }
212             }
213         }
214         return sbnToBeAutogrouped;
215     }
216 
217     /**
218      * A notification was added that's app grouped, or a notification was removed.
219      * Evaluate whether:
220      * (a) an existing autogroup summary needs updated flags
221      * (b) if we need to remove our autogroup overlay for this notification
222      * (c) we need to remove the autogroup summary
223      *
224      * And updates the internal state of un-app-grouped notifications and their flags.
225      */
maybeUngroup(StatusBarNotification sbn, boolean notificationGone, int userId)226     private void maybeUngroup(StatusBarNotification sbn, boolean notificationGone, int userId) {
227         boolean removeSummary = false;
228         int summaryFlags = FLAG_INVALID;
229         boolean updateSummaryFlags = false;
230         boolean removeAutogroupOverlay = false;
231         List<NotificationAttributes> childrenAttrs = new ArrayList<>();
232         synchronized (mUngroupedNotifications) {
233             String key = generatePackageKey(sbn.getUserId(), sbn.getPackageName());
234             final ArrayMap<String, NotificationAttributes> children =
235                     mUngroupedNotifications.getOrDefault(key, new ArrayMap<>());
236             if (children.size() == 0) {
237                 return;
238             }
239 
240             // if this notif was autogrouped and now isn't
241             if (children.containsKey(sbn.getKey())) {
242                 // if this notification was contributing flags that aren't covered by other
243                 // children to the summary, reevaluate flags for the summary
244                 int flags = children.remove(sbn.getKey()).flags;
245                 // this
246                 if (hasAnyFlag(flags, ANY_CHILDREN_FLAGS)) {
247                     updateSummaryFlags = true;
248                     summaryFlags = getAutogroupSummaryFlags(children);
249                 }
250                 // if this notification still exists and has an autogroup overlay, but is now
251                 // grouped by the app, clear the overlay
252                 if (!notificationGone && sbn.getOverrideGroupKey() != null) {
253                     removeAutogroupOverlay = true;
254                 }
255 
256                 // If there are no more children left to autogroup, remove the summary
257                 if (children.size() == 0) {
258                     removeSummary = true;
259                 } else {
260                     childrenAttrs.addAll(children.values());
261                 }
262             }
263         }
264 
265         if (removeSummary) {
266             mCallback.removeAutoGroupSummary(userId, sbn.getPackageName());
267         } else {
268             NotificationAttributes attr = new NotificationAttributes(summaryFlags,
269                     sbn.getNotification().getSmallIcon(), sbn.getNotification().color,
270                     VISIBILITY_PRIVATE);
271             boolean attributesUpdated = false;
272             if (Flags.autogroupSummaryIconUpdate()) {
273                 NotificationAttributes newAttr = updateAutobundledSummaryAttributes(
274                         sbn.getPackageName(), childrenAttrs, attr);
275                 if (!newAttr.equals(attr)) {
276                     attributesUpdated = true;
277                     attr = newAttr;
278                 }
279             }
280 
281             if (updateSummaryFlags || attributesUpdated) {
282                 mCallback.updateAutogroupSummary(userId, sbn.getPackageName(), attr);
283             }
284         }
285         if (removeAutogroupOverlay) {
286             mCallback.removeAutoGroup(sbn.getKey());
287         }
288     }
289 
290     @VisibleForTesting
getNotGroupedByAppCount(int userId, String pkg)291     int getNotGroupedByAppCount(int userId, String pkg) {
292         synchronized (mUngroupedNotifications) {
293             String key = generatePackageKey(userId, pkg);
294             final ArrayMap<String, NotificationAttributes> children =
295                     mUngroupedNotifications.getOrDefault(key, new ArrayMap<>());
296             return children.size();
297         }
298     }
299 
getAutobundledSummaryAttributes(@onNull String packageName, @NonNull List<NotificationAttributes> childrenAttr)300     NotificationAttributes getAutobundledSummaryAttributes(@NonNull String packageName,
301             @NonNull List<NotificationAttributes> childrenAttr) {
302         Icon newIcon = null;
303         boolean childrenHaveSameIcon = true;
304         int newColor = Notification.COLOR_INVALID;
305         boolean childrenHaveSameColor = true;
306         int newVisibility = VISIBILITY_PRIVATE;
307 
308         // Both the icon drawable and the icon background color are updated according to this rule:
309         // - if all child icons are identical => use the common icon
310         // - if child icons are different: use the monochromatic app icon, if exists.
311         // Otherwise fall back to a generic icon representing a stack.
312         for (NotificationAttributes state: childrenAttr) {
313             // Check for icon
314             if (newIcon == null) {
315                 newIcon = state.icon;
316             } else {
317                 if (!newIcon.sameAs(state.icon)) {
318                     childrenHaveSameIcon = false;
319                 }
320             }
321             // Check for color
322             if (newColor == Notification.COLOR_INVALID) {
323                 newColor = state.iconColor;
324             } else {
325                 if (newColor != state.iconColor) {
326                     childrenHaveSameColor = false;
327                 }
328             }
329             // Check for visibility. If at least one child is public, then set to public
330             if (state.visibility == VISIBILITY_PUBLIC) {
331                 newVisibility = VISIBILITY_PUBLIC;
332             }
333         }
334         if (!childrenHaveSameIcon) {
335             newIcon = getMonochromeAppIcon(packageName);
336         }
337         if (!childrenHaveSameColor) {
338             newColor = COLOR_DEFAULT;
339         }
340 
341         return new NotificationAttributes(0, newIcon, newColor, newVisibility);
342     }
343 
updateAutobundledSummaryAttributes(@onNull String packageName, @NonNull List<NotificationAttributes> childrenAttr, @NonNull NotificationAttributes oldAttr)344     NotificationAttributes updateAutobundledSummaryAttributes(@NonNull String packageName,
345             @NonNull List<NotificationAttributes> childrenAttr,
346             @NonNull NotificationAttributes oldAttr) {
347         NotificationAttributes newAttr = getAutobundledSummaryAttributes(packageName,
348                 childrenAttr);
349         Icon newIcon = newAttr.icon;
350         int newColor = newAttr.iconColor;
351         if (newAttr.icon == null) {
352             newIcon = oldAttr.icon;
353         }
354         if (newAttr.iconColor == Notification.COLOR_INVALID) {
355             newColor = oldAttr.iconColor;
356         }
357 
358         return new NotificationAttributes(oldAttr.flags, newIcon, newColor, newAttr.visibility);
359     }
360 
361     /**
362      * Get the monochrome app icon for an app from the adaptive launcher icon
363      *  or a fallback generic icon for autogroup summaries.
364      *
365      * @param pkg packageName of the app
366      * @return a monochrome app icon or a fallback generic icon
367      */
368     @NonNull
getMonochromeAppIcon(@onNull final String pkg)369     Icon getMonochromeAppIcon(@NonNull final String pkg) {
370         Icon monochromeIcon = null;
371         final int fallbackIconResId = R.drawable.ic_notification_summary_auto;
372         try {
373             final Drawable appIcon = mPackageManager.getApplicationIcon(pkg);
374             if (appIcon instanceof AdaptiveIconDrawable) {
375                 if (((AdaptiveIconDrawable) appIcon).getMonochrome() != null) {
376                     monochromeIcon = Icon.createWithResourceAdaptiveDrawable(pkg,
377                             ((AdaptiveIconDrawable) appIcon).getSourceDrawableResId(), true,
378                             -2.0f * AdaptiveIconDrawable.getExtraInsetFraction());
379                 }
380             }
381         } catch (NameNotFoundException e) {
382             Slog.e(TAG, "Failed to getApplicationIcon() in getMonochromeAppIcon()", e);
383         }
384         if (monochromeIcon != null) {
385             return monochromeIcon;
386         } else {
387             return Icon.createWithResource(mContext, fallbackIconResId);
388         }
389     }
390 
391     protected static class NotificationAttributes {
392         public final int flags;
393         public final int iconColor;
394         public final Icon icon;
395         public final int visibility;
396 
NotificationAttributes(int flags, Icon icon, int iconColor, int visibility)397         public NotificationAttributes(int flags, Icon icon, int iconColor, int visibility) {
398             this.flags = flags;
399             this.icon = icon;
400             this.iconColor = iconColor;
401             this.visibility = visibility;
402         }
403 
NotificationAttributes(@onNull NotificationAttributes attr)404         public NotificationAttributes(@NonNull NotificationAttributes attr) {
405             this.flags = attr.flags;
406             this.icon = attr.icon;
407             this.iconColor = attr.iconColor;
408             this.visibility = attr.visibility;
409         }
410 
411         @Override
equals(Object o)412         public boolean equals(Object o) {
413             if (this == o) {
414                 return true;
415             }
416             if (!(o instanceof NotificationAttributes that)) {
417                 return false;
418             }
419             return flags == that.flags && iconColor == that.iconColor && icon.sameAs(that.icon)
420                     && visibility == that.visibility;
421         }
422 
423         @Override
hashCode()424         public int hashCode() {
425             return Objects.hash(flags, iconColor, icon, visibility);
426         }
427     }
428 
429     protected interface Callback {
addAutoGroup(String key, boolean requestSort)430         void addAutoGroup(String key, boolean requestSort);
removeAutoGroup(String key)431         void removeAutoGroup(String key);
432 
addAutoGroupSummary(int userId, String pkg, String triggeringKey, NotificationAttributes summaryAttr)433         void addAutoGroupSummary(int userId, String pkg, String triggeringKey,
434                 NotificationAttributes summaryAttr);
removeAutoGroupSummary(int user, String pkg)435         void removeAutoGroupSummary(int user, String pkg);
updateAutogroupSummary(int userId, String pkg, NotificationAttributes summaryAttr)436         void updateAutogroupSummary(int userId, String pkg, NotificationAttributes summaryAttr);
437     }
438 }
439