• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 import static android.service.notification.Flags.notificationForceGrouping;
28 import static android.service.notification.Flags.notificationRegroupOnClassification;
29 
30 import android.annotation.FlaggedApi;
31 import android.annotation.IntDef;
32 import android.annotation.NonNull;
33 import android.annotation.Nullable;
34 import android.app.ActivityManager;
35 import android.app.Notification;
36 import android.app.NotificationChannel;
37 import android.app.NotificationManager;
38 import android.app.PendingIntent;
39 import android.content.Context;
40 import android.content.pm.PackageManager;
41 import android.content.pm.PackageManager.NameNotFoundException;
42 import android.graphics.drawable.AdaptiveIconDrawable;
43 import android.graphics.drawable.Drawable;
44 import android.graphics.drawable.Icon;
45 import android.service.notification.StatusBarNotification;
46 import android.text.TextUtils;
47 import android.util.ArrayMap;
48 import android.util.Log;
49 import android.util.Slog;
50 
51 import com.android.internal.R;
52 import com.android.internal.annotations.GuardedBy;
53 import com.android.internal.annotations.VisibleForTesting;
54 
55 import java.io.PrintWriter;
56 import java.lang.annotation.Retention;
57 import java.lang.annotation.RetentionPolicy;
58 import java.util.ArrayList;
59 import java.util.Collection;
60 import java.util.HashSet;
61 import java.util.List;
62 import java.util.Map;
63 import java.util.Map.Entry;
64 import java.util.Objects;
65 import java.util.Set;
66 import java.util.function.Predicate;
67 
68 /**
69  * NotificationManagerService helper for auto-grouping notifications.
70  */
71 public class GroupHelper {
72     private static final String TAG = "GroupHelper";
73     static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
74 
75     protected static final String AUTOGROUP_KEY = "ranker_group";
76 
77     protected static final int FLAG_INVALID = -1;
78 
79     // Flags that all autogroup summaries have
80     protected static final int BASE_FLAGS =
81             FLAG_AUTOGROUP_SUMMARY | FLAG_GROUP_SUMMARY | FLAG_LOCAL_ONLY;
82     // Flag that autogroup summaries inherits if all children have the flag
83     private static final int ALL_CHILDREN_FLAG = FLAG_AUTO_CANCEL;
84     // Flags that autogroup summaries inherits if any child has them
85     private static final int ANY_CHILDREN_FLAGS = FLAG_ONGOING_EVENT | FLAG_NO_CLEAR;
86 
87     protected static final String AGGREGATE_GROUP_KEY = "Aggregate_";
88 
89     // If an app posts more than NotificationManagerService.AUTOGROUP_SPARSE_GROUPS_AT_COUNT groups
90     //  with less than this value, they will be forced grouped
91     private static final int MIN_CHILD_COUNT_TO_AVOID_FORCE_GROUPING = 3;
92 
93     // Regrouping needed because the channel was updated, ie. importance changed
94     static final int REGROUP_REASON_CHANNEL_UPDATE = 0;
95     // Regrouping needed because of notification bundling
96     static final int REGROUP_REASON_BUNDLE = 1;
97     // Regrouping needed because of notification unbundling
98     static final int REGROUP_REASON_UNBUNDLE = 2;
99     // Regrouping needed because of notification unbundling + the original group summary exists
100     static final int REGROUP_REASON_UNBUNDLE_ORIGINAL_GROUP = 3;
101 
102     @IntDef(prefix = { "REGROUP_REASON_" }, value = {
103         REGROUP_REASON_CHANNEL_UPDATE,
104         REGROUP_REASON_BUNDLE,
105         REGROUP_REASON_UNBUNDLE,
106         REGROUP_REASON_UNBUNDLE_ORIGINAL_GROUP,
107     })
108     @Retention(RetentionPolicy.SOURCE)
109     @interface RegroupingReason {}
110 
111     private final Callback mCallback;
112     private final int mAutoGroupAtCount;
113     private final int mAutogroupSparseGroupsAtCount;
114     private final Context mContext;
115     private final PackageManager mPackageManager;
116     private boolean mIsTestHarnessExempted;
117 
118     // Only contains notifications that are not explicitly grouped by the app (aka no group or
119     // sort key).
120     // userId|packageName -> (keys of notifications that aren't in an explicit app group -> flags)
121     @GuardedBy("mUngroupedNotifications")
122     private final ArrayMap<String, ArrayMap<String, NotificationAttributes>> mUngroupedNotifications
123             = new ArrayMap<>();
124 
125     // Contains the list of notifications that should be aggregated (forced grouping)
126     // but there are less than mAutoGroupAtCount per section for a package.
127     // The primary map's key is the full aggregated group key: userId|pkgName|g:groupName
128     // The internal map's key is the notification record key
129     @GuardedBy("mAggregatedNotifications")
130     private final ArrayMap<FullyQualifiedGroupKey, ArrayMap<String, NotificationAttributes>>
131             mUngroupedAbuseNotifications = new ArrayMap<>();
132 
133     // Contains the list of group summaries that were canceled when "singleton groups" were
134     // force grouped. Key is userId|packageName|g:OriginalGroupName. Used to:
135     // 1) remove the original group's children when an app cancels the already removed summary.
136     // 2) perform the same side effects that would happen if the group is removed because
137     //    all its force-regrouped children are removed (e.g. firing its deleteIntent).
138     @GuardedBy("mAggregatedNotifications")
139     private final ArrayMap<FullyQualifiedGroupKey, CachedSummary>
140             mCanceledSummaries = new ArrayMap<>();
141 
142     // Represents the current state of the aggregated (forced grouped) notifications
143     // Key is the full aggregated group key: userId|pkgName|g:groupName
144     // And groupName is "Aggregate_"+sectionName
145     @GuardedBy("mAggregatedNotifications")
146     private final ArrayMap<FullyQualifiedGroupKey, ArrayMap<String, NotificationAttributes>>
147             mAggregatedNotifications = new ArrayMap<>();
148 
149     private static List<NotificationSectioner> NOTIFICATION_SHADE_SECTIONS =
150             getNotificationShadeSections();
151 
152     private static List<NotificationSectioner> NOTIFICATION_BUNDLE_SECTIONS;
153 
getNotificationShadeSections()154     private static List<NotificationSectioner> getNotificationShadeSections() {
155         ArrayList<NotificationSectioner> sectionsList = new ArrayList<>();
156         if (android.service.notification.Flags.notificationClassification()) {
157             sectionsList.addAll(List.of(
158                 new NotificationSectioner("PromotionsSection", 0, (record) ->
159                         NotificationChannel.PROMOTIONS_ID.equals(record.getChannel().getId())
160                         && record.getImportance() < NotificationManager.IMPORTANCE_DEFAULT),
161                 new NotificationSectioner("SocialSection", 0, (record) ->
162                         NotificationChannel.SOCIAL_MEDIA_ID.equals(record.getChannel().getId())
163                         && record.getImportance() < NotificationManager.IMPORTANCE_DEFAULT),
164                 new NotificationSectioner("NewsSection", 0, (record) ->
165                         NotificationChannel.NEWS_ID.equals(record.getChannel().getId())
166                         && record.getImportance() < NotificationManager.IMPORTANCE_DEFAULT),
167                 new NotificationSectioner("RecsSection", 0, (record) ->
168                         NotificationChannel.RECS_ID.equals(record.getChannel().getId())
169                         && record.getImportance() < NotificationManager.IMPORTANCE_DEFAULT)
170                 ));
171 
172             NOTIFICATION_BUNDLE_SECTIONS = new ArrayList<>(sectionsList);
173         }
174 
175         if (Flags.notificationForceGroupConversations()) {
176             // add priority people section
177             sectionsList.add(new NotificationSectioner("PeopleSection(priority)", 1, (record) ->
178                     record.isConversation() && record.getChannel().isImportantConversation()));
179 
180             if (android.app.Flags.sortSectionByTime()) {
181                 // add single people (alerting) section
182                 sectionsList.add(new NotificationSectioner("PeopleSection", 0,
183                         NotificationRecord::isConversation));
184             } else {
185                 // add people alerting section
186                 sectionsList.add(new NotificationSectioner("PeopleSection(alerting)", 1, (record) ->
187                         record.isConversation()
188                         && record.getImportance() >= NotificationManager.IMPORTANCE_DEFAULT));
189                 // add people silent section
190                 sectionsList.add(new NotificationSectioner("PeopleSection(silent)", 1, (record) ->
191                         record.isConversation()
192                         && record.getImportance() < NotificationManager.IMPORTANCE_DEFAULT));
193             }
194         }
195 
196         sectionsList.addAll(List.of(
197             new NotificationSectioner("AlertingSection", 0, (record) ->
198                 record.getImportance() >= NotificationManager.IMPORTANCE_DEFAULT),
199             new NotificationSectioner("SilentSection", 1, (record) ->
200                 record.getImportance() < NotificationManager.IMPORTANCE_DEFAULT)));
201         return sectionsList;
202     }
203 
GroupHelper(Context context, PackageManager packageManager, int autoGroupAtCount, int autoGroupSparseGroupsAtCount, Callback callback)204     public GroupHelper(Context context, PackageManager packageManager, int autoGroupAtCount,
205             int autoGroupSparseGroupsAtCount, Callback callback) {
206         mAutoGroupAtCount = autoGroupAtCount;
207         mCallback =  callback;
208         mContext = context;
209         mPackageManager = packageManager;
210         mAutogroupSparseGroupsAtCount = autoGroupSparseGroupsAtCount;
211         NOTIFICATION_SHADE_SECTIONS = getNotificationShadeSections();
212     }
213 
setTestHarnessExempted(boolean isExempted)214     void setTestHarnessExempted(boolean isExempted) {
215         // Allow E2E tests to post ungrouped notifications
216         mIsTestHarnessExempted = ActivityManager.isRunningInUserTestHarness() && isExempted;
217     }
218 
generatePackageKey(int userId, String pkg)219     private String generatePackageKey(int userId, String pkg) {
220         return userId + "|" + pkg;
221     }
222 
223     @VisibleForTesting
getAutogroupSummaryFlags( @onNull final ArrayMap<String, NotificationAttributes> childrenMap)224     protected static int getAutogroupSummaryFlags(
225             @NonNull final ArrayMap<String, NotificationAttributes> childrenMap) {
226         final Collection<NotificationAttributes> children = childrenMap.values();
227         boolean allChildrenHasFlag = children.size() > 0;
228         int anyChildFlagSet = 0;
229         for (NotificationAttributes childAttr: children) {
230             if (!hasAnyFlag(childAttr.flags, ALL_CHILDREN_FLAG)) {
231                 allChildrenHasFlag = false;
232             }
233             if (hasAnyFlag(childAttr.flags, ANY_CHILDREN_FLAGS)) {
234                 anyChildFlagSet |= (childAttr.flags & ANY_CHILDREN_FLAGS);
235             }
236         }
237         return BASE_FLAGS | (allChildrenHasFlag ? ALL_CHILDREN_FLAG : 0) | anyChildFlagSet;
238     }
239 
hasAnyFlag(int flags, int mask)240     private static boolean hasAnyFlag(int flags, int mask) {
241         return (flags & mask) != 0;
242     }
243 
244     /**
245      * Called when a notification is newly posted. Checks whether that notification, and all other
246      * active notifications should be grouped or ungrouped atuomatically, and returns whether.
247      * @param record The posted notification.
248      * @param autogroupSummaryExists Whether a summary for this notification already exists.
249      * @return Whether the provided notification should be autogrouped synchronously.
250      */
onNotificationPosted(NotificationRecord record, boolean autogroupSummaryExists)251     public boolean onNotificationPosted(NotificationRecord record, boolean autogroupSummaryExists) {
252         boolean sbnToBeAutogrouped = false;
253         try {
254             if (notificationForceGrouping()) {
255                 final StatusBarNotification sbn = record.getSbn();
256                 if (!sbn.isAppGroup()) {
257                     sbnToBeAutogrouped = maybeGroupWithSections(record, autogroupSummaryExists);
258                 } else {
259                     maybeUngroupOnAppGrouped(record);
260                 }
261             } else {
262                 final StatusBarNotification sbn = record.getSbn();
263                 if (!sbn.isAppGroup()) {
264                     sbnToBeAutogrouped = maybeGroup(sbn, autogroupSummaryExists);
265                 } else {
266                     maybeUngroup(sbn, false, sbn.getUserId());
267                 }
268             }
269         } catch (Exception e) {
270             Slog.e(TAG, "Failure processing new notification", e);
271         }
272         return sbnToBeAutogrouped;
273     }
274 
275     /**
276      * Called when a notification was removed. Checks if that notification was part of an autogroup
277      * and triggers any necessary cleanups: summary removal, clearing caches etc.
278      *
279      * @param record The removed notification.
280      */
onNotificationRemoved(NotificationRecord record)281     public void onNotificationRemoved(NotificationRecord record) {
282         try {
283             if (notificationForceGrouping()) {
284                 Slog.wtf(TAG,
285                         "This overload of onNotificationRemoved() should not be called if "
286                                 + "notification_force_grouping is enabled!",
287                         new Exception("call stack"));
288                 onNotificationRemoved(record, new ArrayList<>(), false);
289             } else {
290                 final StatusBarNotification sbn = record.getSbn();
291                 maybeUngroup(sbn, true, sbn.getUserId());
292             }
293         } catch (Exception e) {
294             Slog.e(TAG, "Error processing canceled notification", e);
295         }
296     }
297 
298     /**
299      * A non-app grouped notification has been added or updated
300      * Evaluate if:
301      * (a) an existing autogroup summary needs updated flags
302      * (b) a new autogroup summary needs to be added with correct flags
303      * (c) other non-app grouped children need to be moved to the autogroup
304      *
305      * And stores the list of upgrouped notifications & their flags
306      */
maybeGroup(StatusBarNotification sbn, boolean autogroupSummaryExists)307     private boolean maybeGroup(StatusBarNotification sbn, boolean autogroupSummaryExists) {
308         int flags = 0;
309         List<String> notificationsToGroup = new ArrayList<>();
310         List<NotificationAttributes> childrenAttr = new ArrayList<>();
311         // Indicates whether the provided sbn should be autogrouped by the caller.
312         boolean sbnToBeAutogrouped = false;
313         synchronized (mUngroupedNotifications) {
314             String packageKey = generatePackageKey(sbn.getUserId(), sbn.getPackageName());
315             final ArrayMap<String, NotificationAttributes> children =
316                     mUngroupedNotifications.getOrDefault(packageKey, new ArrayMap<>());
317             NotificationAttributes attr = new NotificationAttributes(sbn.getNotification().flags,
318                     sbn.getNotification().getSmallIcon(), sbn.getNotification().color,
319                     sbn.getNotification().visibility, Notification.GROUP_ALERT_CHILDREN,
320                     sbn.getNotification().getChannelId());
321             children.put(sbn.getKey(), attr);
322             mUngroupedNotifications.put(packageKey, children);
323 
324             if (children.size() >= mAutoGroupAtCount || autogroupSummaryExists) {
325                 flags = getAutogroupSummaryFlags(children);
326                 notificationsToGroup.addAll(children.keySet());
327                 childrenAttr.addAll(children.values());
328             }
329         }
330         if (notificationsToGroup.size() > 0) {
331             if (autogroupSummaryExists) {
332                 NotificationAttributes attr = new NotificationAttributes(flags,
333                         sbn.getNotification().getSmallIcon(), sbn.getNotification().color,
334                         VISIBILITY_PRIVATE, Notification.GROUP_ALERT_CHILDREN,
335                         sbn.getNotification().getChannelId());
336                 if (Flags.autogroupSummaryIconUpdate()) {
337                     attr = updateAutobundledSummaryAttributes(sbn.getPackageName(), childrenAttr,
338                             attr);
339                 }
340 
341                 mCallback.updateAutogroupSummary(sbn.getUserId(), sbn.getPackageName(),
342                         AUTOGROUP_KEY, attr);
343             } else {
344                 Icon summaryIcon = sbn.getNotification().getSmallIcon();
345                 int summaryIconColor = sbn.getNotification().color;
346                 int summaryVisibility = VISIBILITY_PRIVATE;
347                 String summaryChannelId = sbn.getNotification().getChannelId();
348                 if (Flags.autogroupSummaryIconUpdate()) {
349                     // Calculate the initial summary icon, icon color and visibility
350                     NotificationAttributes iconAttr = getAutobundledSummaryAttributes(
351                             sbn.getPackageName(), childrenAttr);
352                     summaryIcon = iconAttr.icon;
353                     summaryIconColor = iconAttr.iconColor;
354                     summaryVisibility = iconAttr.visibility;
355                     summaryChannelId = iconAttr.channelId;
356                 }
357 
358                 NotificationAttributes attr = new NotificationAttributes(flags, summaryIcon,
359                         summaryIconColor, summaryVisibility, Notification.GROUP_ALERT_CHILDREN,
360                         summaryChannelId);
361                 mCallback.addAutoGroupSummary(sbn.getUserId(), sbn.getPackageName(), sbn.getKey(),
362                         AUTOGROUP_KEY, Integer.MAX_VALUE, attr);
363             }
364             for (String keyToGroup : notificationsToGroup) {
365                 if (android.app.Flags.checkAutogroupBeforePost()) {
366                     if (keyToGroup.equals(sbn.getKey())) {
367                         // Autogrouping for the provided notification is to be done synchronously.
368                         sbnToBeAutogrouped = true;
369                     } else {
370                         mCallback.addAutoGroup(keyToGroup, AUTOGROUP_KEY, /*requestSort=*/true);
371                     }
372                 } else {
373                     mCallback.addAutoGroup(keyToGroup, AUTOGROUP_KEY, /*requestSort=*/true);
374                 }
375             }
376         }
377         return sbnToBeAutogrouped;
378     }
379 
380     /**
381      * A notification was added that's app grouped, or a notification was removed.
382      * Evaluate whether:
383      * (a) an existing autogroup summary needs updated flags
384      * (b) if we need to remove our autogroup overlay for this notification
385      * (c) we need to remove the autogroup summary
386      *
387      * And updates the internal state of un-app-grouped notifications and their flags.
388      */
maybeUngroup(StatusBarNotification sbn, boolean notificationGone, int userId)389     private void maybeUngroup(StatusBarNotification sbn, boolean notificationGone, int userId) {
390         boolean removeSummary = false;
391         int summaryFlags = FLAG_INVALID;
392         boolean updateSummaryFlags = false;
393         boolean removeAutogroupOverlay = false;
394         List<NotificationAttributes> childrenAttrs = new ArrayList<>();
395         synchronized (mUngroupedNotifications) {
396             String key = generatePackageKey(sbn.getUserId(), sbn.getPackageName());
397             final ArrayMap<String, NotificationAttributes> children =
398                     mUngroupedNotifications.getOrDefault(key, new ArrayMap<>());
399             if (children.size() == 0) {
400                 return;
401             }
402 
403             // if this notif was autogrouped and now isn't
404             if (children.containsKey(sbn.getKey())) {
405                 // if this notification was contributing flags that aren't covered by other
406                 // children to the summary, reevaluate flags for the summary
407                 int flags = children.remove(sbn.getKey()).flags;
408                 // this
409                 if (hasAnyFlag(flags, ANY_CHILDREN_FLAGS)) {
410                     updateSummaryFlags = true;
411                     summaryFlags = getAutogroupSummaryFlags(children);
412                 }
413                 // if this notification still exists and has an autogroup overlay, but is now
414                 // grouped by the app, clear the overlay
415                 if (!notificationGone && sbn.getOverrideGroupKey() != null) {
416                     removeAutogroupOverlay = true;
417                 }
418 
419                 // If there are no more children left to autogroup, remove the summary
420                 if (children.size() == 0) {
421                     removeSummary = true;
422                 } else {
423                     childrenAttrs.addAll(children.values());
424                 }
425             }
426         }
427 
428         if (removeSummary) {
429             mCallback.removeAutoGroupSummary(userId, sbn.getPackageName(), AUTOGROUP_KEY);
430         } else {
431             NotificationAttributes attr = new NotificationAttributes(summaryFlags,
432                     sbn.getNotification().getSmallIcon(), sbn.getNotification().color,
433                     VISIBILITY_PRIVATE, Notification.GROUP_ALERT_CHILDREN,
434                     sbn.getNotification().getChannelId());
435             boolean attributesUpdated = false;
436             if (Flags.autogroupSummaryIconUpdate()) {
437                 NotificationAttributes newAttr = updateAutobundledSummaryAttributes(
438                         sbn.getPackageName(), childrenAttrs, attr);
439                 if (!newAttr.equals(attr)) {
440                     attributesUpdated = true;
441                     attr = newAttr;
442                 }
443             }
444 
445             if (updateSummaryFlags || attributesUpdated) {
446                 mCallback.updateAutogroupSummary(userId, sbn.getPackageName(), AUTOGROUP_KEY, attr);
447             }
448         }
449         if (removeAutogroupOverlay) {
450             mCallback.removeAutoGroup(sbn.getKey());
451         }
452     }
453 
getAutobundledSummaryAttributes(@onNull String packageName, @NonNull List<NotificationAttributes> childrenAttr)454     NotificationAttributes getAutobundledSummaryAttributes(@NonNull String packageName,
455             @NonNull List<NotificationAttributes> childrenAttr) {
456         Icon newIcon = null;
457         boolean childrenHaveSameIcon = true;
458         int newColor = Notification.COLOR_INVALID;
459         boolean childrenHaveSameColor = true;
460         int newVisibility = VISIBILITY_PRIVATE;
461 
462         // Both the icon drawable and the icon background color are updated according to this rule:
463         // - if all child icons are identical => use the common icon
464         // - if child icons are different: use the monochromatic app icon, if exists.
465         // Otherwise fall back to a generic icon representing a stack.
466         for (NotificationAttributes state: childrenAttr) {
467             // Check for icon
468             if (newIcon == null) {
469                 newIcon = state.icon;
470             } else {
471                 if (!newIcon.sameAs(state.icon)) {
472                     childrenHaveSameIcon = false;
473                 }
474             }
475             // Check for color
476             if (newColor == Notification.COLOR_INVALID) {
477                 newColor = state.iconColor;
478             } else {
479                 if (newColor != state.iconColor) {
480                     childrenHaveSameColor = false;
481                 }
482             }
483             // Check for visibility. If at least one child is public, then set to public
484             if (state.visibility == VISIBILITY_PUBLIC) {
485                 newVisibility = VISIBILITY_PUBLIC;
486             }
487         }
488         if (!childrenHaveSameIcon) {
489             newIcon = getMonochromeAppIcon(packageName);
490         }
491         if (!childrenHaveSameColor) {
492             newColor = COLOR_DEFAULT;
493         }
494 
495         // Use GROUP_ALERT_CHILDREN
496         // Unless all children have GROUP_ALERT_SUMMARY => avoid muting all notifications in group
497         int newGroupAlertBehavior = Notification.GROUP_ALERT_SUMMARY;
498         for (NotificationAttributes attr: childrenAttr) {
499             if (attr.groupAlertBehavior != Notification.GROUP_ALERT_SUMMARY) {
500                 newGroupAlertBehavior = Notification.GROUP_ALERT_CHILDREN;
501                 break;
502             }
503         }
504 
505         String channelId = !childrenAttr.isEmpty() ? childrenAttr.get(0).channelId : null;
506 
507         return new NotificationAttributes(0, newIcon, newColor, newVisibility,
508                 newGroupAlertBehavior, channelId);
509     }
510 
updateAutobundledSummaryAttributes(@onNull String packageName, @NonNull List<NotificationAttributes> childrenAttr, @NonNull NotificationAttributes oldAttr)511     NotificationAttributes updateAutobundledSummaryAttributes(@NonNull String packageName,
512             @NonNull List<NotificationAttributes> childrenAttr,
513             @NonNull NotificationAttributes oldAttr) {
514         NotificationAttributes newAttr = getAutobundledSummaryAttributes(packageName,
515                 childrenAttr);
516         Icon newIcon = newAttr.icon;
517         int newColor = newAttr.iconColor;
518         String newChannelId = newAttr.channelId;
519         if (newAttr.icon == null) {
520             newIcon = oldAttr.icon;
521         }
522         if (newAttr.iconColor == Notification.COLOR_INVALID) {
523             newColor = oldAttr.iconColor;
524         }
525         if (newAttr.channelId == null) {
526             newChannelId = oldAttr.channelId;
527         }
528 
529         return new NotificationAttributes(oldAttr.flags, newIcon, newColor, newAttr.visibility,
530                 oldAttr.groupAlertBehavior, newChannelId);
531     }
532 
getSummaryAttributes(String pkgName, ArrayMap<String, NotificationAttributes> childrenMap)533     private NotificationAttributes getSummaryAttributes(String pkgName,
534             ArrayMap<String, NotificationAttributes> childrenMap) {
535         int flags = getAutogroupSummaryFlags(childrenMap);
536         NotificationAttributes attr = getAutobundledSummaryAttributes(pkgName,
537                 childrenMap.values().stream().toList());
538         return new NotificationAttributes(flags, attr.icon, attr.iconColor, attr.visibility,
539                 attr.groupAlertBehavior, attr.channelId);
540     }
541 
542     /**
543      * Get the monochrome app icon for an app from the adaptive launcher icon
544      *  or a fallback generic icon for autogroup summaries.
545      *
546      * @param pkg packageName of the app
547      * @return a monochrome app icon or a fallback generic icon
548      */
549     @NonNull
getMonochromeAppIcon(@onNull final String pkg)550     Icon getMonochromeAppIcon(@NonNull final String pkg) {
551         Icon monochromeIcon = null;
552         final int fallbackIconResId = R.drawable.ic_notification_summary_auto;
553         try {
554             final Drawable appIcon = mPackageManager.getApplicationIcon(pkg);
555             if (appIcon instanceof AdaptiveIconDrawable) {
556                 if (((AdaptiveIconDrawable) appIcon).getMonochrome() != null) {
557                     monochromeIcon = Icon.createWithResourceAdaptiveDrawable(pkg,
558                             ((AdaptiveIconDrawable) appIcon).getSourceDrawableResId(), true,
559                             -2.0f * AdaptiveIconDrawable.getExtraInsetFraction());
560                 }
561             }
562         } catch (NameNotFoundException e) {
563             Slog.e(TAG, "Failed to getApplicationIcon() in getMonochromeAppIcon()", e);
564         }
565         if (monochromeIcon != null) {
566             return monochromeIcon;
567         } else {
568             return Icon.createWithResource(mContext, fallbackIconResId);
569         }
570     }
571 
572     /**
573      * A non-app-grouped notification has been added or updated
574      * Evaluate if:
575      * (a) an existing autogroup summary needs updated attributes
576      * (b) a new autogroup summary needs to be added with correct attributes
577      * (c) other non-app grouped children need to be moved to the autogroup
578      * (d) the notification has been updated from a groupable to a non-groupable section and needs
579      *  to trigger a cleanup
580      *
581      * This method implements autogrouping with sections support.
582      *
583      * And stores the list of upgrouped notifications & their flags
584      */
maybeGroupWithSections(NotificationRecord record, boolean autogroupSummaryExists)585     private boolean maybeGroupWithSections(NotificationRecord record,
586             boolean autogroupSummaryExists) {
587         final StatusBarNotification sbn = record.getSbn();
588         boolean sbnToBeAutogrouped = false;
589         final NotificationSectioner sectioner = getSection(record);
590         if (sectioner == null) {
591             maybeUngroupOnNonGroupableUpdate(record);
592             if (DEBUG) {
593                 Slog.i(TAG, "Skipping autogrouping for " + record + " no valid section found.");
594             }
595             return false;
596         }
597 
598         final String pkgName = sbn.getPackageName();
599         final FullyQualifiedGroupKey fullAggregateGroupKey = new FullyQualifiedGroupKey(
600                 record.getUserId(), pkgName, sectioner);
601 
602         // The notification was part of a different section => trigger regrouping
603         final FullyQualifiedGroupKey prevSectionKey = getPreviousValidSectionKey(record);
604         if (prevSectionKey != null && !fullAggregateGroupKey.equals(prevSectionKey)) {
605             if (DEBUG) {
606                 Slog.i(TAG, "Section changed for: " + record);
607             }
608             maybeUngroupOnSectionChanged(record, prevSectionKey);
609         }
610 
611         // This notification is already aggregated
612         if (record.getGroupKey().equals(fullAggregateGroupKey.toString())) {
613             return false;
614         }
615         synchronized (mAggregatedNotifications) {
616             ArrayMap<String, NotificationAttributes> ungrouped =
617                 mUngroupedAbuseNotifications.getOrDefault(fullAggregateGroupKey, new ArrayMap<>());
618             ungrouped.put(record.getKey(), new NotificationAttributes(
619                 record.getFlags(),
620                 record.getNotification().getSmallIcon(),
621                 record.getNotification().color,
622                 record.getNotification().visibility,
623                 record.getNotification().getGroupAlertBehavior(),
624                 record.getChannel().getId()));
625             mUngroupedAbuseNotifications.put(fullAggregateGroupKey, ungrouped);
626 
627             // scenario 0: ungrouped notifications
628             if (ungrouped.size() >= mAutoGroupAtCount || autogroupSummaryExists) {
629                 if (DEBUG) {
630                     if (ungrouped.size() >= mAutoGroupAtCount) {
631                         Slog.i(TAG,
632                             "Found >=" + mAutoGroupAtCount
633                                 + " ungrouped notifications => force grouping");
634                     } else {
635                         Slog.i(TAG, "Found aggregate summary => force grouping");
636                     }
637                 }
638 
639                 final ArrayMap<String, NotificationAttributes> aggregatedNotificationsAttrs =
640                     mAggregatedNotifications.getOrDefault(fullAggregateGroupKey, new ArrayMap<>());
641                 aggregatedNotificationsAttrs.putAll(ungrouped);
642                 mAggregatedNotifications.put(fullAggregateGroupKey, aggregatedNotificationsAttrs);
643 
644                 // add/update aggregate summary
645                 updateAggregateAppGroup(fullAggregateGroupKey, record.getKey(),
646                         autogroupSummaryExists, sectioner.mSummaryId);
647 
648                 // add notification to aggregate group
649                 for (String keyToGroup : ungrouped.keySet()) {
650                     if (android.app.Flags.checkAutogroupBeforePost()) {
651                         if (keyToGroup.equals(record.getKey())) {
652                             // Autogrouping for the posted notification is to be done synchronously.
653                             sbnToBeAutogrouped = true;
654                         } else {
655                             mCallback.addAutoGroup(keyToGroup, fullAggregateGroupKey.toString(),
656                                     true);
657                         }
658                     } else {
659                         mCallback.addAutoGroup(keyToGroup, fullAggregateGroupKey.toString(), true);
660                     }
661                 }
662 
663                 //cleanup mUngroupedAbuseNotifications
664                 mUngroupedAbuseNotifications.remove(fullAggregateGroupKey);
665             }
666         }
667 
668         return sbnToBeAutogrouped;
669     }
670 
671     /**
672      * A notification was added that was previously part of a valid section and needs to trigger
673      * GH state cleanup.
674      */
maybeUngroupOnNonGroupableUpdate(NotificationRecord record)675     private void maybeUngroupOnNonGroupableUpdate(NotificationRecord record) {
676         maybeUngroupWithSections(record, getPreviousValidSectionKey(record));
677     }
678 
679     /**
680      * A notification was added that was previously part of a different section and needs to trigger
681      * GH state cleanup.
682      */
maybeUngroupOnSectionChanged(NotificationRecord record, FullyQualifiedGroupKey prevSectionKey)683     private void maybeUngroupOnSectionChanged(NotificationRecord record,
684             FullyQualifiedGroupKey prevSectionKey) {
685         maybeUngroupWithSections(record, prevSectionKey);
686         if (record.getGroupKey().equals(prevSectionKey.toString())) {
687             record.setOverrideGroupKey(null);
688         }
689     }
690 
691     /**
692      * A notification was added that is app-grouped.
693      */
maybeUngroupOnAppGrouped(NotificationRecord record)694     private void maybeUngroupOnAppGrouped(NotificationRecord record) {
695         FullyQualifiedGroupKey currentSectionKey = getSectionGroupKeyWithFallback(record);
696 
697         // The notification was part of a different section => trigger regrouping
698         final FullyQualifiedGroupKey prevSectionKey = getPreviousValidSectionKey(record);
699         if (prevSectionKey != null && !prevSectionKey.equals(currentSectionKey)) {
700             if (DEBUG) {
701                 Slog.i(TAG, "Section changed for: " + record);
702             }
703             currentSectionKey = prevSectionKey;
704         }
705 
706         maybeUngroupWithSections(record, currentSectionKey);
707     }
708 
709     /**
710      * Called when a notification is posted and is either app-grouped or was previously part of
711      * a valid section and needs to trigger GH state cleanup.
712      *
713      * Evaluate whether:
714      * (a) an existing autogroup summary needs updated attributes
715      * (b) if we need to remove our autogroup overlay for this notification
716      * (c) we need to remove the autogroup summary
717      *
718      * This method implements autogrouping with sections support.
719      *
720      * And updates the internal state of un-app-grouped notifications and their flags.
721      */
maybeUngroupWithSections(NotificationRecord record, @Nullable FullyQualifiedGroupKey fullAggregateGroupKey)722     private void maybeUngroupWithSections(NotificationRecord record,
723             @Nullable FullyQualifiedGroupKey fullAggregateGroupKey) {
724         if (fullAggregateGroupKey == null) {
725             if (DEBUG) {
726                 Slog.i(TAG,
727                         "Skipping maybeUngroupWithSections for " + record
728                             + " no valid section found.");
729             }
730             return;
731         }
732 
733         final StatusBarNotification sbn = record.getSbn();
734         final String pkgName = sbn.getPackageName();
735         final int userId = record.getUserId();
736         synchronized (mAggregatedNotifications) {
737             // if this notification still exists and has an autogroup overlay, but is now
738             // grouped by the app, clear the overlay
739             ArrayMap<String, NotificationAttributes> ungrouped =
740                 mUngroupedAbuseNotifications.getOrDefault(fullAggregateGroupKey, new ArrayMap<>());
741             ungrouped.remove(sbn.getKey());
742             mUngroupedAbuseNotifications.put(fullAggregateGroupKey, ungrouped);
743 
744             final ArrayMap<String, NotificationAttributes> aggregatedNotificationsAttrs =
745                 mAggregatedNotifications.getOrDefault(fullAggregateGroupKey, new ArrayMap<>());
746             // check if the removed notification was part of the aggregate group
747             if (aggregatedNotificationsAttrs.containsKey(record.getKey())) {
748                 aggregatedNotificationsAttrs.remove(sbn.getKey());
749                 mAggregatedNotifications.put(fullAggregateGroupKey, aggregatedNotificationsAttrs);
750 
751                 if (DEBUG) {
752                     Slog.i(TAG, "maybeUngroup removeAutoGroup: " + record);
753                 }
754 
755                 mCallback.removeAutoGroup(sbn.getKey());
756 
757                 if (aggregatedNotificationsAttrs.isEmpty()) {
758                     if (DEBUG) {
759                         Slog.i(TAG, "Aggregate group is empty: " + fullAggregateGroupKey);
760                     }
761                     mCallback.removeAutoGroupSummary(userId, pkgName,
762                             fullAggregateGroupKey.toString());
763                     mAggregatedNotifications.remove(fullAggregateGroupKey);
764                 } else {
765                     if (DEBUG) {
766                         Slog.i(TAG,
767                                 "Aggregate group not empty, updating: " + fullAggregateGroupKey);
768                     }
769                     updateAggregateAppGroup(fullAggregateGroupKey, sbn.getKey(), true, 0);
770                 }
771             }
772         }
773     }
774 
775     /**
776      * Called when a notification is newly posted, after some delay, so that the app
777      * has a chance to post a group summary or children (complete a group).
778      * Checks whether that notification and other active notifications should be forced grouped
779      * because their grouping is incorrect:
780      *  - missing summary
781      *  - only summaries
782      *  - sparse groups == multiple groups with very few notifications
783      *
784      * @param record the notification that was posted
785      * @param notificationList the full notification list from NotificationManagerService
786      * @param summaryByGroupKey the map of group summaries from NotificationManagerService
787      */
788     @FlaggedApi(android.service.notification.Flags.FLAG_NOTIFICATION_FORCE_GROUPING)
onNotificationPostedWithDelay(final NotificationRecord record, final List<NotificationRecord> notificationList, final Map<String, NotificationRecord> summaryByGroupKey)789     protected void onNotificationPostedWithDelay(final NotificationRecord record,
790             final List<NotificationRecord> notificationList,
791             final Map<String, NotificationRecord> summaryByGroupKey) {
792         // Ungrouped notifications are handled separately in
793         // {@link #onNotificationPosted(StatusBarNotification, boolean)}
794         final StatusBarNotification sbn = record.getSbn();
795         if (!sbn.isAppGroup()) {
796             return;
797         }
798 
799         if (record.isCanceled) {
800             return;
801         }
802 
803         if (mIsTestHarnessExempted) {
804             return;
805         }
806 
807         final NotificationSectioner sectioner = getSection(record);
808         if (sectioner == null) {
809             if (DEBUG) {
810                 Log.i(TAG, "Skipping autogrouping for " + record + " no valid section found.");
811             }
812             return;
813         }
814 
815         final String pkgName = sbn.getPackageName();
816         final FullyQualifiedGroupKey fullAggregateGroupKey = new FullyQualifiedGroupKey(
817                 record.getUserId(), pkgName, sectioner);
818 
819         // This notification is already aggregated
820         if (record.getGroupKey().equals(fullAggregateGroupKey.toString())) {
821             return;
822         }
823 
824         synchronized (mAggregatedNotifications) {
825             // scenario 1: group w/o summary
826             // scenario 2: summary w/o children
827             if (isGroupChildWithoutSummary(record, summaryByGroupKey) ||
828                 isGroupSummaryWithoutChildren(record, notificationList)) {
829                 if (DEBUG) {
830                     Log.i(TAG, "isGroupChildWithoutSummary OR isGroupSummaryWithoutChild"
831                             + record);
832                 }
833                 addToUngroupedAndMaybeAggregate(record, fullAggregateGroupKey, sectioner);
834                 return;
835             }
836 
837             // Check if summary & child notifications are not part of the same section/bundle
838             // Needs a check here if notification was bundled while enqueued
839             if (notificationRegroupOnClassification()
840                     && android.service.notification.Flags.notificationClassification()) {
841                 if (isGroupChildBundled(record, summaryByGroupKey)) {
842                     if (DEBUG) {
843                         Slog.v(TAG, "isGroupChildInDifferentBundleThanSummary: " + record);
844                     }
845                     moveNotificationsToNewSection(record.getUserId(), pkgName,
846                             List.of(new NotificationMoveOp(record, null, fullAggregateGroupKey)),
847                             Map.of(record.getKey(), REGROUP_REASON_BUNDLE));
848                     return;
849                 }
850             }
851 
852             // scenario 3: sparse/singleton groups
853             if (Flags.notificationForceGroupSingletons()) {
854                 try {
855                     groupSparseGroups(record, notificationList, summaryByGroupKey, sectioner,
856                             fullAggregateGroupKey);
857                 } catch (Throwable e) {
858                     Slog.wtf(TAG, "Failed to group sparse groups", e);
859                 }
860             }
861         }
862     }
863 
864     @GuardedBy("mAggregatedNotifications")
addToUngroupedAndMaybeAggregate(NotificationRecord record, FullyQualifiedGroupKey fullAggregateGroupKey, NotificationSectioner sectioner)865     private void addToUngroupedAndMaybeAggregate(NotificationRecord record,
866             FullyQualifiedGroupKey fullAggregateGroupKey, NotificationSectioner sectioner) {
867         ArrayMap<String, NotificationAttributes> ungrouped =
868                 mUngroupedAbuseNotifications.getOrDefault(fullAggregateGroupKey,
869                     new ArrayMap<>());
870         ungrouped.put(record.getKey(), new NotificationAttributes(
871                 record.getFlags(),
872                 record.getNotification().getSmallIcon(),
873                 record.getNotification().color,
874                 record.getNotification().visibility,
875                 record.getNotification().getGroupAlertBehavior(),
876                 record.getChannel().getId()));
877         mUngroupedAbuseNotifications.put(fullAggregateGroupKey, ungrouped);
878         // Create/update summary and group if >= mAutoGroupAtCount notifications
879         //  or if aggregate group exists
880         boolean hasSummary = !mAggregatedNotifications.getOrDefault(fullAggregateGroupKey,
881                 new ArrayMap<>()).isEmpty();
882         if (ungrouped.size() >= mAutoGroupAtCount || hasSummary) {
883             if (DEBUG) {
884                 if (ungrouped.size() >= mAutoGroupAtCount) {
885                     Slog.i(TAG, "Found >=" + mAutoGroupAtCount
886                             + " ungrouped notifications => force grouping");
887                 } else {
888                     Slog.i(TAG, "Found aggregate summary => force grouping");
889                 }
890             }
891             aggregateUngroupedNotifications(fullAggregateGroupKey, record.getKey(),
892                     ungrouped, hasSummary, sectioner.mSummaryId);
893         }
894     }
895 
isGroupChildBundled(final NotificationRecord record, final Map<String, NotificationRecord> summaryByGroupKey)896     private static boolean isGroupChildBundled(final NotificationRecord record,
897             final Map<String, NotificationRecord> summaryByGroupKey) {
898         final StatusBarNotification sbn = record.getSbn();
899         final String groupKey = record.getSbn().getGroupKey();
900 
901         if (!sbn.isAppGroup()) {
902             return false;
903         }
904 
905         if (record.getNotification().isGroupSummary()) {
906             return false;
907         }
908 
909         final NotificationRecord summary = summaryByGroupKey.get(groupKey);
910         if (summary == null) {
911             return false;
912         }
913 
914         return isInBundleSection(record);
915     }
916 
isInBundleSection(final NotificationRecord record)917     private static boolean isInBundleSection(final NotificationRecord record) {
918         final NotificationSectioner sectioner = getSection(record);
919         return (sectioner != null && NOTIFICATION_BUNDLE_SECTIONS.contains(sectioner));
920     }
921 
922     /**
923      * Called when a notification is removed, so that this helper can adjust the aggregate groups:
924      *  - Removes the autogroup summary of the notification's section
925      *      if the record was the last child.
926      *  - Recalculates the autogroup summary "attributes":
927      *      icon, icon color, visibility, groupAlertBehavior, flags - if the removed record was
928      *  part of an autogroup.
929      *  - Removes the saved summary of the original group, if the record was the last remaining
930      *      child of a sparse group that was forced auto-grouped.
931      *
932      * see also {@link #onNotificationPostedWithDelay(NotificationRecord, List, Map)}
933      *
934      * @param record the removed notification
935      * @param notificationList the full notification list from NotificationManagerService
936      * @param sendingDelete whether the removed notification is being removed in a way that sends
937      *                     its {@code deleteIntent}
938      */
939     @FlaggedApi(android.service.notification.Flags.FLAG_NOTIFICATION_FORCE_GROUPING)
onNotificationRemoved(final NotificationRecord record, final List<NotificationRecord> notificationList, boolean sendingDelete)940     protected void onNotificationRemoved(final NotificationRecord record,
941             final List<NotificationRecord> notificationList, boolean sendingDelete) {
942         final StatusBarNotification sbn = record.getSbn();
943         final String pkgName = sbn.getPackageName();
944         final int userId = record.getUserId();
945 
946         final FullyQualifiedGroupKey fullAggregateGroupKey = getSectionGroupKeyWithFallback(record);
947         if (fullAggregateGroupKey == null) {
948             if (DEBUG) {
949                 Slog.i(TAG,
950                         "Skipping autogroup cleanup for " + record + " no valid section found.");
951             }
952             return;
953         }
954 
955         synchronized (mAggregatedNotifications) {
956             ArrayMap<String, NotificationAttributes> ungrouped =
957                 mUngroupedAbuseNotifications.getOrDefault(fullAggregateGroupKey, new ArrayMap<>());
958             ungrouped.remove(record.getKey());
959             mUngroupedAbuseNotifications.put(fullAggregateGroupKey, ungrouped);
960 
961             final ArrayMap<String, NotificationAttributes> aggregatedNotificationsAttrs =
962                 mAggregatedNotifications.getOrDefault(fullAggregateGroupKey, new ArrayMap<>());
963             // check if the removed notification was part of the aggregate group
964             if (record.getGroupKey().equals(fullAggregateGroupKey.toString())
965                     || aggregatedNotificationsAttrs.containsKey(record.getKey())) {
966                 aggregatedNotificationsAttrs.remove(record.getKey());
967                 mAggregatedNotifications.put(fullAggregateGroupKey, aggregatedNotificationsAttrs);
968 
969                 if (aggregatedNotificationsAttrs.isEmpty()) {
970                     if (DEBUG) {
971                         Slog.i(TAG, "Aggregate group is empty: " + fullAggregateGroupKey);
972                     }
973                     mCallback.removeAutoGroupSummary(userId, pkgName,
974                             fullAggregateGroupKey.toString());
975                     mAggregatedNotifications.remove(fullAggregateGroupKey);
976                 } else {
977                     if (DEBUG) {
978                         Slog.i(TAG,
979                                 "Aggregate group not empty, updating: " + fullAggregateGroupKey);
980                     }
981                     updateAggregateAppGroup(fullAggregateGroupKey, sbn.getKey(), true, 0);
982                 }
983 
984                 // Try to cleanup cached summaries if notification was canceled (not snoozed)
985                 // If the notification was cancelled by an action that fires its delete intent,
986                 // also fire it for the cached summary.
987                 if (record.isCanceled) {
988                     maybeClearCanceledSummariesCache(pkgName, userId,
989                             record.getNotification().getGroup(), notificationList, sendingDelete);
990                 }
991             }
992         }
993     }
994 
995     /**
996      * Get the section key for a notification. If the section is invalid, ie. notification is not
997      * auto-groupable, then return the previous valid section, if any.
998      * @param record the notification
999      * @return a section group key, null if not found
1000      */
1001     @Nullable
getSectionGroupKeyWithFallback(final NotificationRecord record)1002     private FullyQualifiedGroupKey getSectionGroupKeyWithFallback(final NotificationRecord record) {
1003         final NotificationSectioner sectioner = getSection(record);
1004         if (sectioner != null) {
1005             return FullyQualifiedGroupKey.forRecord(record, sectioner);
1006         } else {
1007             return getPreviousValidSectionKey(record);
1008         }
1009     }
1010 
1011     /**
1012      * Get the previous valid section key of a notification that may have been updated to an invalid
1013      * section. This is needed in case a notification is updated as an ungroupable (invalid section)
1014      *  => auto-groups need to be updated/GH state cleanup.
1015      * @param record the notification
1016      * @return a section group key or null if not found
1017      */
1018     @Nullable
getPreviousValidSectionKey(final NotificationRecord record)1019     private FullyQualifiedGroupKey getPreviousValidSectionKey(final NotificationRecord record) {
1020         synchronized (mAggregatedNotifications) {
1021             final String recordKey = record.getKey();
1022             // Search in ungrouped
1023             for (Entry<FullyQualifiedGroupKey, ArrayMap<String, NotificationAttributes>>
1024                         ungroupedSection : mUngroupedAbuseNotifications.entrySet()) {
1025                 if (ungroupedSection.getValue().containsKey(recordKey)) {
1026                     return ungroupedSection.getKey();
1027                 }
1028             }
1029             // Search in aggregated
1030             for (Entry<FullyQualifiedGroupKey, ArrayMap<String, NotificationAttributes>>
1031                     aggregatedSection : mAggregatedNotifications.entrySet()) {
1032                 if (aggregatedSection.getValue().containsKey(recordKey)) {
1033                     return aggregatedSection.getKey();
1034                 }
1035             }
1036         }
1037         return null;
1038     }
1039 
1040     /**
1041      * Called when a child notification is removed, after some delay, so that this helper can
1042      * trigger a forced grouping if the group has become sparse/singleton
1043      * or only the summary is left.
1044      *
1045      * see also {@link #onNotificationPostedWithDelay(NotificationRecord, List, Map)}
1046      *
1047      * @param summaryRecord the group summary of the notification that was removed
1048      * @param notificationList the full notification list from NotificationManagerService
1049      * @param summaryByGroupKey the map of group summaries from NotificationManagerService
1050      */
1051     @FlaggedApi(android.service.notification.Flags.FLAG_NOTIFICATION_FORCE_GROUPING)
onGroupedNotificationRemovedWithDelay(final NotificationRecord summaryRecord, final List<NotificationRecord> notificationList, final Map<String, NotificationRecord> summaryByGroupKey)1052     protected void onGroupedNotificationRemovedWithDelay(final NotificationRecord summaryRecord,
1053             final List<NotificationRecord> notificationList,
1054             final Map<String, NotificationRecord> summaryByGroupKey) {
1055         final StatusBarNotification sbn = summaryRecord.getSbn();
1056         if (!sbn.isAppGroup()) {
1057             return;
1058         }
1059 
1060         if (summaryRecord.isCanceled) {
1061             return;
1062         }
1063 
1064         if (mIsTestHarnessExempted) {
1065             return;
1066         }
1067 
1068         final NotificationSectioner sectioner = getSection(summaryRecord);
1069         if (sectioner == null) {
1070             if (DEBUG) {
1071                 Slog.i(TAG,
1072                         "Skipping autogrouping for " + summaryRecord + " no valid section found.");
1073             }
1074             return;
1075         }
1076 
1077         final String pkgName = sbn.getPackageName();
1078         final FullyQualifiedGroupKey fullAggregateGroupKey = new FullyQualifiedGroupKey(
1079                 summaryRecord.getUserId(), pkgName, sectioner);
1080 
1081         // This notification is already aggregated
1082         if (summaryRecord.getGroupKey().equals(fullAggregateGroupKey.toString())) {
1083             return;
1084         }
1085 
1086         synchronized (mAggregatedNotifications) {
1087             if (isGroupSummaryWithoutChildren(summaryRecord, notificationList)) {
1088                 if (DEBUG) {
1089                     Slog.i(TAG, "isGroupSummaryWithoutChild " + summaryRecord);
1090                 }
1091                 addToUngroupedAndMaybeAggregate(summaryRecord, fullAggregateGroupKey, sectioner);
1092                 return;
1093             }
1094 
1095             // Check if notification removal turned this group into a sparse/singleton group
1096             if (Flags.notificationForceGroupSingletons()) {
1097                 try {
1098                     groupSparseGroups(summaryRecord, notificationList, summaryByGroupKey, sectioner,
1099                             fullAggregateGroupKey);
1100                 } catch (Throwable e) {
1101                     Slog.wtf(TAG, "Failed to group sparse groups", e);
1102                 }
1103             }
1104         }
1105     }
1106 
1107     /**
1108      * Called when a group summary is posted. If there are any ungrouped notifications that are
1109      * in that group, remove them as they are no longer candidates for autogrouping.
1110      *
1111      * @param summaryRecord the NotificationRecord for the newly posted group summary
1112      * @param notificationList the full notification list from NotificationManagerService
1113      */
1114     @FlaggedApi(android.service.notification.Flags.FLAG_NOTIFICATION_FORCE_GROUPING)
onGroupSummaryAdded(final NotificationRecord summaryRecord, final List<NotificationRecord> notificationList)1115     protected void onGroupSummaryAdded(final NotificationRecord summaryRecord,
1116             final List<NotificationRecord> notificationList) {
1117         String groupKey = summaryRecord.getSbn().getGroup();
1118         synchronized (mAggregatedNotifications) {
1119             final NotificationSectioner sectioner = getSection(summaryRecord);
1120             if (sectioner == null) {
1121                 Slog.w(TAG, "onGroupSummaryAdded " + summaryRecord + ": no valid section found");
1122                 return;
1123             }
1124 
1125             FullyQualifiedGroupKey aggregateGroupKey = FullyQualifiedGroupKey.forRecord(
1126                     summaryRecord, sectioner);
1127             ArrayMap<String, NotificationAttributes> ungrouped =
1128                     mUngroupedAbuseNotifications.getOrDefault(aggregateGroupKey,
1129                             new ArrayMap<>());
1130             if (ungrouped.isEmpty()) {
1131                 // don't bother looking through the notification list if there are no pending
1132                 // ungrouped notifications in this section (likely to be the most common case)
1133                 return;
1134             }
1135 
1136             // Look through full notification list for any notifications belonging to this group;
1137             // remove from ungrouped map if needed, as the presence of the summary means they will
1138             // now be grouped
1139             for (NotificationRecord r : notificationList) {
1140                 if (!r.getNotification().isGroupSummary()
1141                         && groupKey.equals(r.getSbn().getGroup())
1142                         && ungrouped.containsKey(r.getKey())) {
1143                     ungrouped.remove(r.getKey());
1144                 }
1145             }
1146             mUngroupedAbuseNotifications.put(aggregateGroupKey, ungrouped);
1147         }
1148     }
1149 
NotificationMoveOp(NotificationRecord record, FullyQualifiedGroupKey oldGroup, FullyQualifiedGroupKey newGroup)1150     private record NotificationMoveOp(NotificationRecord record, FullyQualifiedGroupKey oldGroup,
1151                                       FullyQualifiedGroupKey newGroup) { }
1152 
1153     /**
1154      * Called when a notification channel is updated (channel attributes have changed), so that this
1155      * helper can adjust the aggregate groups by moving children if their section has changed. see
1156      * {@link #onNotificationPostedWithDelay(NotificationRecord, List, Map)}
1157      *
1158      * @param userId the userId of the channel
1159      * @param pkgName the channel's package
1160      * @param channel the channel that was updated
1161      * @param notificationList the full notification list from NotificationManagerService
1162      */
1163     @FlaggedApi(android.service.notification.Flags.FLAG_NOTIFICATION_FORCE_GROUPING)
onChannelUpdated(final int userId, final String pkgName, final NotificationChannel channel, final List<NotificationRecord> notificationList, ArrayMap<String, NotificationRecord> summaryByGroupKey)1164     public void onChannelUpdated(final int userId, final String pkgName,
1165             final NotificationChannel channel, final List<NotificationRecord> notificationList,
1166             ArrayMap<String, NotificationRecord> summaryByGroupKey) {
1167         synchronized (mAggregatedNotifications) {
1168             final ArrayMap<String, Integer> regroupingReasonMap = new ArrayMap<>();
1169             ArrayMap<String, NotificationRecord> notificationsToCheck = new ArrayMap<>();
1170             for (NotificationRecord r : notificationList) {
1171                 if (r.getChannel().getId().equals(channel.getId())
1172                     && r.getSbn().getPackageName().equals(pkgName)
1173                     && r.getUserId() == userId) {
1174                     notificationsToCheck.put(r.getKey(), r);
1175                     regroupingReasonMap.put(r.getKey(), REGROUP_REASON_CHANNEL_UPDATE);
1176                     if (notificationRegroupOnClassification()) {
1177                         // Notification is unbundled and original summary found
1178                         // => regroup in original group
1179                         if (!isInBundleSection(r)
1180                                 && isOriginalGroupSummaryPresent(r, summaryByGroupKey)) {
1181                             regroupingReasonMap.put(r.getKey(),
1182                                     REGROUP_REASON_UNBUNDLE_ORIGINAL_GROUP);
1183                         }
1184                     }
1185                 }
1186             }
1187 
1188             regroupNotifications(userId, pkgName, notificationsToCheck, regroupingReasonMap);
1189         }
1190     }
1191 
1192     /**
1193      * Called when an individuial notification's channel is updated (moved to a new channel),
1194      * so that this helper can adjust the aggregate groups by moving children
1195      * if their section has changed.
1196      * see {@link #onNotificationPostedWithDelay(NotificationRecord, List, Map)}
1197      *
1198      * @param record the notification which had its channel updated
1199      */
1200     @FlaggedApi(android.service.notification.Flags.FLAG_NOTIFICATION_FORCE_GROUPING)
onChannelUpdated(final NotificationRecord record)1201     public void onChannelUpdated(final NotificationRecord record) {
1202         synchronized (mAggregatedNotifications) {
1203             ArrayMap<String, NotificationRecord> notificationsToCheck = new ArrayMap<>();
1204             notificationsToCheck.put(record.getKey(), record);
1205             ArrayMap<String, Integer> regroupReasons = new ArrayMap<>();
1206             regroupReasons.put(record.getKey(), REGROUP_REASON_BUNDLE);
1207             regroupNotifications(record.getUserId(), record.getSbn().getPackageName(),
1208                     notificationsToCheck, regroupReasons);
1209         }
1210     }
1211 
1212     /**
1213      * Called when a notification that was classified (bundled) is restored to its original channel.
1214      * The notification will be restored to its original group, if any/if summary still exists.
1215      * Otherwise it will be moved to the appropriate section as an ungrouped notification.
1216      *
1217      * @param record the notification which had its channel updated
1218      * @param originalSummaryExists the original group summary exists
1219      */
1220     @FlaggedApi(android.service.notification.Flags.FLAG_NOTIFICATION_FORCE_GROUPING)
onNotificationUnbundled(final NotificationRecord record, final boolean originalSummaryExists)1221     public void onNotificationUnbundled(final NotificationRecord record,
1222             final boolean originalSummaryExists) {
1223         synchronized (mAggregatedNotifications) {
1224             ArrayMap<String, NotificationRecord> notificationsToCheck = new ArrayMap<>();
1225             notificationsToCheck.put(record.getKey(), record);
1226             regroupNotifications(record.getUserId(), record.getSbn().getPackageName(),
1227                     notificationsToCheck, Map.of(record.getKey(),
1228                         originalSummaryExists ? REGROUP_REASON_UNBUNDLE_ORIGINAL_GROUP
1229                             : REGROUP_REASON_UNBUNDLE));
1230         }
1231     }
1232 
1233     @GuardedBy("mAggregatedNotifications")
regroupNotifications(int userId, String pkgName, ArrayMap<String, NotificationRecord> notificationsToCheck, Map<String, Integer> regroupReasons)1234     private void regroupNotifications(int userId, String pkgName,
1235             ArrayMap<String, NotificationRecord> notificationsToCheck,
1236             Map<String, Integer> regroupReasons) {
1237         // The list of notification operations required after the channel update
1238         final ArrayList<NotificationMoveOp> notificationsToMove = new ArrayList<>();
1239 
1240         // Check any already auto-grouped notifications that may need to be re-grouped
1241         // after the channel update
1242         notificationsToMove.addAll(
1243                 getAutogroupedNotificationsMoveOps(userId, pkgName,
1244                     notificationsToCheck));
1245 
1246         // Check any ungrouped notifications that may need to be auto-grouped
1247         // after the channel update
1248         notificationsToMove.addAll(
1249                 getUngroupedNotificationsMoveOps(userId, pkgName, notificationsToCheck));
1250 
1251         // Handle "grouped correctly" notifications that were re-classified (bundled)
1252         if (notificationRegroupOnClassification()) {
1253             notificationsToMove.addAll(
1254                     getReclassifiedNotificationsMoveOps(userId, pkgName, notificationsToCheck));
1255         }
1256 
1257         // Batch move to new section
1258         if (!notificationsToMove.isEmpty()) {
1259             moveNotificationsToNewSection(userId, pkgName, notificationsToMove, regroupReasons);
1260         }
1261     }
1262 
getReclassifiedNotificationsMoveOps(int userId, String pkgName, ArrayMap<String, NotificationRecord> notificationsToCheck)1263     private List<NotificationMoveOp> getReclassifiedNotificationsMoveOps(int userId,
1264                 String pkgName, ArrayMap<String, NotificationRecord> notificationsToCheck) {
1265         final ArrayList<NotificationMoveOp> notificationsToMove = new ArrayList<>();
1266         for (NotificationRecord record : notificationsToCheck.values()) {
1267             if (isChildOfValidAppGroup(record)) {
1268                 // Check if section changes to a bundle section
1269                 NotificationSectioner sectioner = getSection(record);
1270                 if (sectioner != null && NOTIFICATION_BUNDLE_SECTIONS.contains(sectioner)) {
1271                     FullyQualifiedGroupKey newFullAggregateGroupKey =
1272                             new FullyQualifiedGroupKey(userId, pkgName, sectioner);
1273                     if (DEBUG) {
1274                         Slog.v(TAG, "Regroup after classification: " + record + " to: "
1275                                 + newFullAggregateGroupKey);
1276                     }
1277                     notificationsToMove.add(
1278                             new NotificationMoveOp(record, null, newFullAggregateGroupKey));
1279                 }
1280             }
1281         }
1282         return notificationsToMove;
1283     }
1284 
1285     /**
1286      *  Checks if the original group's summary exists for a notification that was regrouped
1287      * @param r notification to check
1288      * @param summaryByGroupKey map of the current group summaries
1289      * @return true if the original group summary exists
1290      */
isOriginalGroupSummaryPresent(final NotificationRecord r, final ArrayMap<String, NotificationRecord> summaryByGroupKey)1291     public static boolean isOriginalGroupSummaryPresent(final NotificationRecord r,
1292             final ArrayMap<String, NotificationRecord> summaryByGroupKey) {
1293         if (r.getSbn().isAppGroup() && r.getNotification().isGroupChild()) {
1294             final String oldGroupKey = GroupHelper.getFullAggregateGroupKey(
1295                     r.getSbn().getPackageName(), r.getOriginalGroupKey(), r.getUserId());
1296             NotificationRecord groupSummary = summaryByGroupKey.get(oldGroupKey);
1297             // We only care about app-provided valid groups
1298             return (groupSummary != null && !GroupHelper.isAggregatedGroup(groupSummary));
1299         }
1300         return false;
1301     }
1302 
1303     @GuardedBy("mAggregatedNotifications")
getAutogroupedNotificationsMoveOps(int userId, String pkgName, ArrayMap<String, NotificationRecord> notificationsToCheck)1304     private List<NotificationMoveOp> getAutogroupedNotificationsMoveOps(int userId, String pkgName,
1305             ArrayMap<String, NotificationRecord> notificationsToCheck) {
1306         final ArrayList<NotificationMoveOp> notificationsToMove = new ArrayList<>();
1307         final Set<FullyQualifiedGroupKey> oldGroups =
1308                 new HashSet<>(mAggregatedNotifications.keySet());
1309         // Move auto-grouped updated notifications from the old groups to the new groups (section)
1310         for (FullyQualifiedGroupKey oldFullAggKey : oldGroups) {
1311             // Only check aggregate groups that match the same userId & packageName
1312             if (pkgName.equals(oldFullAggKey.pkg) && userId == oldFullAggKey.userId) {
1313                 final ArrayMap<String, NotificationAttributes> notificationsInAggGroup =
1314                         mAggregatedNotifications.get(oldFullAggKey);
1315                 if (notificationsInAggGroup == null) {
1316                     continue;
1317                 }
1318 
1319                 FullyQualifiedGroupKey newFullAggregateGroupKey = null;
1320                 for (String key : notificationsInAggGroup.keySet()) {
1321                     if (notificationsToCheck.get(key) != null) {
1322                         // check if section changes
1323                         NotificationSectioner sectioner = getSection(notificationsToCheck.get(key));
1324                         if (sectioner == null) {
1325                             continue;
1326                         }
1327                         newFullAggregateGroupKey = new FullyQualifiedGroupKey(userId, pkgName,
1328                                 sectioner);
1329                         if (!oldFullAggKey.equals(newFullAggregateGroupKey)) {
1330                             if (DEBUG) {
1331                                 Log.i(TAG, "Change section on channel update: " + key);
1332                             }
1333                             notificationsToMove.add(
1334                                     new NotificationMoveOp(notificationsToCheck.get(key),
1335                                         oldFullAggKey, newFullAggregateGroupKey));
1336                             notificationsToCheck.remove(key);
1337                         }
1338                     }
1339                 }
1340             }
1341         }
1342         return notificationsToMove;
1343     }
1344 
1345     @GuardedBy("mAggregatedNotifications")
getUngroupedNotificationsMoveOps(int userId, String pkgName, final ArrayMap<String, NotificationRecord> notificationsToCheck)1346     private List<NotificationMoveOp> getUngroupedNotificationsMoveOps(int userId, String pkgName,
1347             final ArrayMap<String, NotificationRecord> notificationsToCheck) {
1348         final ArrayList<NotificationMoveOp> notificationsToMove = new ArrayList<>();
1349         // Move any remaining ungrouped updated notifications from the old ungrouped list
1350         // to the new ungrouped section list, if necessary
1351         if (!notificationsToCheck.isEmpty()) {
1352             final Set<FullyQualifiedGroupKey> oldUngroupedSectionKeys =
1353                     new HashSet<>(mUngroupedAbuseNotifications.keySet());
1354             for (FullyQualifiedGroupKey oldFullAggKey : oldUngroupedSectionKeys) {
1355                 // Only check aggregate groups that match the same userId & packageName
1356                 if (pkgName.equals(oldFullAggKey.pkg) && userId == oldFullAggKey.userId) {
1357                     final ArrayMap<String, NotificationAttributes> ungroupedOld =
1358                             mUngroupedAbuseNotifications.get(oldFullAggKey);
1359                     if (ungroupedOld == null) {
1360                         continue;
1361                     }
1362 
1363                     FullyQualifiedGroupKey newFullAggregateGroupKey = null;
1364                     final Set<String> ungroupedKeys = new HashSet<>(ungroupedOld.keySet());
1365                     for (String key : ungroupedKeys) {
1366                         NotificationRecord record = notificationsToCheck.get(key);
1367                         if (record != null) {
1368                             // check if section changes
1369                             NotificationSectioner sectioner = getSection(record);
1370                             if (sectioner == null) {
1371                                 continue;
1372                             }
1373                             newFullAggregateGroupKey = new FullyQualifiedGroupKey(userId, pkgName,
1374                                     sectioner);
1375                             if (!oldFullAggKey.equals(newFullAggregateGroupKey)) {
1376                                 if (DEBUG) {
1377                                     Log.i(TAG, "Change ungrouped section: " + key);
1378                                 }
1379                                 notificationsToMove.add(
1380                                         new NotificationMoveOp(record, oldFullAggKey,
1381                                             newFullAggregateGroupKey));
1382                                 notificationsToCheck.remove(key);
1383                                 //Remove from previous ungrouped list
1384                                 ungroupedOld.remove(key);
1385                             }
1386                         }
1387                     }
1388                     mUngroupedAbuseNotifications.put(oldFullAggKey, ungroupedOld);
1389                 }
1390             }
1391         }
1392         return notificationsToMove;
1393     }
1394 
1395     @GuardedBy("mAggregatedNotifications")
moveNotificationsToNewSection(final int userId, final String pkgName, final List<NotificationMoveOp> notificationsToMove, final Map<String, Integer> regroupReasons)1396     private void moveNotificationsToNewSection(final int userId, final String pkgName,
1397             final List<NotificationMoveOp> notificationsToMove,
1398             final Map<String, Integer> regroupReasons) {
1399         record GroupUpdateOp(FullyQualifiedGroupKey groupKey, NotificationRecord record,
1400                              boolean hasSummary) { }
1401         // Bundled operations to apply to groups affected by the channel update
1402         ArrayMap<FullyQualifiedGroupKey, GroupUpdateOp> groupsToUpdate = new ArrayMap<>();
1403 
1404         // App-provided (valid) groups of notifications that were classified (bundled).
1405         // Summaries will be canceled if all child notifications have been bundled.
1406         ArrayMap<String, String> originalGroupsOfBundledNotifications = new ArrayMap<>();
1407 
1408         for (NotificationMoveOp moveOp: notificationsToMove) {
1409             final NotificationRecord record = moveOp.record;
1410             final FullyQualifiedGroupKey oldFullAggregateGroupKey = moveOp.oldGroup;
1411             final FullyQualifiedGroupKey newFullAggregateGroupKey = moveOp.newGroup;
1412 
1413             if (DEBUG) {
1414                 Log.i(TAG,
1415                     "moveNotificationToNewSection: " + record + " " + newFullAggregateGroupKey
1416                             + " from: " + oldFullAggregateGroupKey + " regroupingReason: "
1417                             + regroupReasons);
1418             }
1419 
1420             // Update/remove aggregate summary for old group
1421             if (oldFullAggregateGroupKey != null) {
1422                 final ArrayMap<String, NotificationAttributes> oldAggregatedNotificationsAttrs =
1423                         mAggregatedNotifications.getOrDefault(oldFullAggregateGroupKey,
1424                             new ArrayMap<>());
1425                 oldAggregatedNotificationsAttrs.remove(record.getKey());
1426                 mAggregatedNotifications.put(oldFullAggregateGroupKey,
1427                         oldAggregatedNotificationsAttrs);
1428 
1429                 // Only add once, for triggering notification
1430                 if (!groupsToUpdate.containsKey(oldFullAggregateGroupKey)) {
1431                     groupsToUpdate.put(oldFullAggregateGroupKey,
1432                         new GroupUpdateOp(oldFullAggregateGroupKey, record, true));
1433                 }
1434             } else {
1435                 if (notificationRegroupOnClassification()) {
1436                     // Null "old aggregate group" => this notification was re-classified from
1437                     // a valid app-provided group => maybe cancel the original summary
1438                     // if no children are left
1439                     originalGroupsOfBundledNotifications.put(record.getKey(), record.getGroupKey());
1440                 }
1441             }
1442 
1443             // Add moved notifications to the ungrouped list for new group and do grouping
1444             // after all notifications have been handled
1445             if (newFullAggregateGroupKey != null) {
1446                 if (notificationRegroupOnClassification()
1447                     && regroupReasons.getOrDefault(record.getKey(), REGROUP_REASON_CHANNEL_UPDATE)
1448                         == REGROUP_REASON_UNBUNDLE_ORIGINAL_GROUP) {
1449                     // Just reset override group key, original summary exists
1450                     // => will be grouped back to its original group
1451                     record.setOverrideGroupKey(null);
1452                 } else {
1453                     final ArrayMap<String, NotificationAttributes> newAggregatedNotificationsAttrs =
1454                         mAggregatedNotifications.getOrDefault(newFullAggregateGroupKey,
1455                             new ArrayMap<>());
1456                     boolean hasSummary = !newAggregatedNotificationsAttrs.isEmpty();
1457                     ArrayMap<String, NotificationAttributes> ungrouped =
1458                         mUngroupedAbuseNotifications.getOrDefault(newFullAggregateGroupKey,
1459                             new ArrayMap<>());
1460                     ungrouped.put(record.getKey(), new NotificationAttributes(
1461                         record.getFlags(),
1462                         record.getNotification().getSmallIcon(),
1463                         record.getNotification().color,
1464                         record.getNotification().visibility,
1465                         record.getNotification().getGroupAlertBehavior(),
1466                         record.getChannel().getId()));
1467                     mUngroupedAbuseNotifications.put(newFullAggregateGroupKey, ungrouped);
1468 
1469                     record.setOverrideGroupKey(null);
1470 
1471                     // Only add once, for triggering notification
1472                     if (!groupsToUpdate.containsKey(newFullAggregateGroupKey)) {
1473                         groupsToUpdate.put(newFullAggregateGroupKey,
1474                             new GroupUpdateOp(newFullAggregateGroupKey, record, hasSummary));
1475                     }
1476                 }
1477             }
1478         }
1479 
1480         // Update groups (sections)
1481         for (FullyQualifiedGroupKey groupKey : groupsToUpdate.keySet()) {
1482             final ArrayMap<String, NotificationAttributes> aggregatedNotificationsAttrs =
1483                     mAggregatedNotifications.getOrDefault(groupKey, new ArrayMap<>());
1484             final ArrayMap<String, NotificationAttributes> ungrouped =
1485                     mUngroupedAbuseNotifications.getOrDefault(groupKey, new ArrayMap<>());
1486 
1487             NotificationRecord triggeringNotification = groupsToUpdate.get(groupKey).record;
1488             boolean hasSummary = groupsToUpdate.get(groupKey).hasSummary;
1489             //Group needs to be created/updated
1490             if (ungrouped.size() >= mAutoGroupAtCount
1491                     || (hasSummary && !aggregatedNotificationsAttrs.isEmpty())) {
1492                 NotificationSectioner sectioner = getSection(triggeringNotification);
1493                 if (sectioner == null) {
1494                     continue;
1495                 }
1496                 aggregateUngroupedNotifications(groupKey, triggeringNotification.getKey(),
1497                         ungrouped, hasSummary, sectioner.mSummaryId);
1498             } else {
1499                 // Remove empty groups
1500                 if (aggregatedNotificationsAttrs.isEmpty() && hasSummary) {
1501                     mCallback.removeAutoGroupSummary(userId, pkgName, groupKey.toString());
1502                     mAggregatedNotifications.remove(groupKey);
1503                 }
1504             }
1505         }
1506 
1507         if (notificationRegroupOnClassification()) {
1508             // Cancel the summary if it's the last notification of the original app-provided group
1509             for (String triggeringKey : originalGroupsOfBundledNotifications.keySet()) {
1510                 NotificationRecord canceledSummary =
1511                         mCallback.removeAppProvidedSummaryOnClassification(triggeringKey,
1512                         originalGroupsOfBundledNotifications.getOrDefault(triggeringKey, null));
1513                 if (canceledSummary != null) {
1514                     cacheCanceledSummary(canceledSummary);
1515                 }
1516             }
1517         }
1518     }
1519 
getFullAggregateGroupKey(String pkgName, String groupName, int userId)1520     static String getFullAggregateGroupKey(String pkgName,
1521             String groupName, int userId) {
1522         return new FullyQualifiedGroupKey(userId, pkgName, groupName).toString();
1523     }
1524 
1525     /**
1526      * Returns the full aggregate group key, which contains the userId and package name
1527      * in addition to the aggregate group key (name).
1528      * Equivalent to {@link StatusBarNotification#groupKey()}
1529      */
getFullAggregateGroupKey(NotificationRecord record)1530     static String getFullAggregateGroupKey(NotificationRecord record) {
1531         return new FullyQualifiedGroupKey(record.getUserId(), record.getSbn().getPackageName(),
1532                 getSection(record)).toString();
1533     }
1534 
isAggregatedGroup(NotificationRecord record)1535     protected static boolean isAggregatedGroup(NotificationRecord record) {
1536         return (record.mOriginalFlags & Notification.FLAG_AUTOGROUP_SUMMARY) != 0;
1537     }
1538 
isNotificationAggregatedInSection(NotificationRecord record, NotificationSectioner sectioner)1539     private boolean isNotificationAggregatedInSection(NotificationRecord record,
1540             NotificationSectioner sectioner) {
1541         final FullyQualifiedGroupKey fullAggregateGroupKey = FullyQualifiedGroupKey.forRecord(
1542                 record, sectioner);
1543         return record.getGroupKey().equals(fullAggregateGroupKey.toString());
1544     }
1545 
isChildOfValidAppGroup(NotificationRecord record)1546     private boolean isChildOfValidAppGroup(NotificationRecord record) {
1547         final StatusBarNotification sbn = record.getSbn();
1548         if (!sbn.isAppGroup()) {
1549             return false;
1550         }
1551 
1552         if (!sbn.getNotification().isGroupChild()) {
1553             return false;
1554         }
1555 
1556         if (record.isCanceled) {
1557             return false;
1558         }
1559 
1560         final NotificationSectioner sectioner = getSection(record);
1561         if (sectioner == null) {
1562             if (DEBUG) {
1563                 Slog.i(TAG, "Skipping autogrouping for " + record + " no valid section found.");
1564             }
1565             return false;
1566         }
1567 
1568         if (isNotificationAggregatedInSection(record, sectioner)) {
1569             return false;
1570         }
1571 
1572         return true;
1573     }
1574 
getNumChildrenForGroup(@onNull final String groupKey, final List<NotificationRecord> notificationList)1575     private static int getNumChildrenForGroup(@NonNull final String groupKey,
1576             final List<NotificationRecord> notificationList) {
1577         //TODO (b/349072751): track grouping state in GroupHelper -> do not use notificationList
1578         int numChildren = 0;
1579         // find children for this summary
1580         for (NotificationRecord r : notificationList) {
1581             if (!r.getNotification().isGroupSummary()
1582                     && groupKey.equals(r.getSbn().getGroup())) {
1583                 numChildren++;
1584             }
1585         }
1586 
1587         if (DEBUG) {
1588             Log.i(TAG, "getNumChildrenForGroup " + groupKey + " numChild: " + numChildren);
1589         }
1590         return numChildren;
1591     }
1592 
isGroupSummaryWithoutChildren(final NotificationRecord record, final List<NotificationRecord> notificationList)1593     private static boolean isGroupSummaryWithoutChildren(final NotificationRecord record,
1594             final List<NotificationRecord> notificationList) {
1595         final StatusBarNotification sbn = record.getSbn();
1596         final String groupKey = record.getSbn().getGroup();
1597 
1598         // ignore non app groups and non summaries
1599         if (!sbn.isAppGroup() || !record.getNotification().isGroupSummary()) {
1600             return false;
1601         }
1602 
1603         return getNumChildrenForGroup(groupKey, notificationList) == 0;
1604     }
1605 
isGroupChildWithoutSummary(final NotificationRecord record, final Map<String, NotificationRecord> summaryByGroupKey)1606     private static boolean isGroupChildWithoutSummary(final NotificationRecord record,
1607             final Map<String, NotificationRecord> summaryByGroupKey) {
1608         final StatusBarNotification sbn = record.getSbn();
1609         final String groupKey = record.getSbn().getGroupKey();
1610 
1611         if (!sbn.isAppGroup()) {
1612             return false;
1613         }
1614 
1615         if (record.getNotification().isGroupSummary()) {
1616             return false;
1617         }
1618 
1619         if (summaryByGroupKey.containsKey(groupKey)) {
1620             return false;
1621         }
1622 
1623         return true;
1624     }
1625 
1626     @GuardedBy("mAggregatedNotifications")
aggregateUngroupedNotifications(FullyQualifiedGroupKey fullAggregateGroupKey, String triggeringNotifKey, Map<String, NotificationAttributes> ungrouped, final boolean hasSummary, int summaryId)1627     private void aggregateUngroupedNotifications(FullyQualifiedGroupKey fullAggregateGroupKey,
1628             String triggeringNotifKey, Map<String, NotificationAttributes> ungrouped,
1629             final boolean hasSummary, int summaryId) {
1630         final ArrayMap<String, NotificationAttributes> aggregatedNotificationsAttrs =
1631                 mAggregatedNotifications.getOrDefault(fullAggregateGroupKey, new ArrayMap<>());
1632         aggregatedNotificationsAttrs.putAll(ungrouped);
1633         mAggregatedNotifications.put(fullAggregateGroupKey, aggregatedNotificationsAttrs);
1634 
1635         // add/update aggregate summary
1636         updateAggregateAppGroup(fullAggregateGroupKey, triggeringNotifKey, hasSummary, summaryId);
1637 
1638         // add notification to aggregate group
1639         for (String key: ungrouped.keySet()) {
1640             mCallback.addAutoGroup(key, fullAggregateGroupKey.toString(), true);
1641         }
1642 
1643         //cleanup mUngroupedAbuseNotifications
1644         mUngroupedAbuseNotifications.remove(fullAggregateGroupKey);
1645     }
1646 
1647     @GuardedBy("mAggregatedNotifications")
updateAggregateAppGroup(FullyQualifiedGroupKey fullAggregateGroupKey, String triggeringNotifKey, boolean hasSummary, int summaryId)1648     private void updateAggregateAppGroup(FullyQualifiedGroupKey fullAggregateGroupKey,
1649             String triggeringNotifKey, boolean hasSummary, int summaryId) {
1650         final ArrayMap<String, NotificationAttributes> aggregatedNotificationsAttrs =
1651                 mAggregatedNotifications.getOrDefault(fullAggregateGroupKey, new ArrayMap<>());
1652         NotificationAttributes attr = getSummaryAttributes(fullAggregateGroupKey.pkg,
1653                 aggregatedNotificationsAttrs);
1654         String channelId = hasSummary ? attr.channelId
1655                 : aggregatedNotificationsAttrs.get(triggeringNotifKey).channelId;
1656         NotificationAttributes summaryAttr = new NotificationAttributes(attr.flags, attr.icon,
1657                 attr.iconColor, attr.visibility, attr.groupAlertBehavior, channelId);
1658 
1659         if (!hasSummary) {
1660             if (DEBUG) {
1661                 Log.i(TAG, "Create aggregate summary: " + fullAggregateGroupKey);
1662             }
1663             mCallback.addAutoGroupSummary(fullAggregateGroupKey.userId, fullAggregateGroupKey.pkg,
1664                     triggeringNotifKey, fullAggregateGroupKey.toString(), summaryId, summaryAttr);
1665         } else {
1666             if (DEBUG) {
1667                 Log.i(TAG, "Update aggregate summary: " + fullAggregateGroupKey);
1668             }
1669             mCallback.updateAutogroupSummary(fullAggregateGroupKey.userId,
1670                     fullAggregateGroupKey.pkg, fullAggregateGroupKey.toString(), summaryAttr);
1671         }
1672     }
1673 
1674     @GuardedBy("mAggregatedNotifications")
groupSparseGroups(final NotificationRecord record, final List<NotificationRecord> notificationList, final Map<String, NotificationRecord> summaryByGroupKey, final NotificationSectioner sectioner, final FullyQualifiedGroupKey fullAggregateGroupKey)1675     private void groupSparseGroups(final NotificationRecord record,
1676             final List<NotificationRecord> notificationList,
1677             final Map<String, NotificationRecord> summaryByGroupKey,
1678             final NotificationSectioner sectioner,
1679             final FullyQualifiedGroupKey fullAggregateGroupKey) {
1680         final ArrayMap<String, NotificationRecord> sparseGroupSummaries = getSparseGroups(
1681                 fullAggregateGroupKey, notificationList, summaryByGroupKey, sectioner);
1682         if (sparseGroupSummaries.size() >= mAutogroupSparseGroupsAtCount) {
1683             if (DEBUG) {
1684                 Log.i(TAG,
1685                     "Aggregate sparse groups for: " + record.getSbn().getPackageName()
1686                         + " Section: " + sectioner.mName);
1687             }
1688 
1689             ArrayMap<String, NotificationAttributes> ungrouped =
1690                     mUngroupedAbuseNotifications.getOrDefault(
1691                         fullAggregateGroupKey, new ArrayMap<>());
1692             final ArrayMap<String, NotificationAttributes> aggregatedNotificationsAttrs =
1693                     mAggregatedNotifications.getOrDefault(fullAggregateGroupKey, new ArrayMap<>());
1694             final boolean hasSummary = !aggregatedNotificationsAttrs.isEmpty();
1695             String triggeringKey = null;
1696             if (!record.getNotification().isGroupSummary()) {
1697                 // Use this record as triggeringKey only if not a group summary (will be removed)
1698                 triggeringKey = record.getKey();
1699             }
1700             for (NotificationRecord r : notificationList) {
1701                 // Add notifications for detected sparse groups
1702                 if (sparseGroupSummaries.containsKey(r.getGroupKey())) {
1703                     // Move child notifications to aggregate group
1704                     if (!r.getNotification().isGroupSummary()) {
1705                         if (DEBUG) {
1706                             Log.i(TAG, "Aggregate notification (sparse group): " + r);
1707                         }
1708                         mCallback.addAutoGroup(r.getKey(), fullAggregateGroupKey.toString(), true);
1709                         aggregatedNotificationsAttrs.put(r.getKey(),
1710                             new NotificationAttributes(r.getFlags(),
1711                                 r.getNotification().getSmallIcon(), r.getNotification().color,
1712                                 r.getNotification().visibility,
1713                                 r.getNotification().getGroupAlertBehavior(),
1714                                 r.getChannel().getId()));
1715 
1716                         // Pick the first valid triggeringKey
1717                         if (triggeringKey == null) {
1718                             triggeringKey = r.getKey();
1719                         }
1720                     } else if (r.getNotification().isGroupSummary()) {
1721                         // Remove summary notifications
1722                         if (DEBUG) {
1723                             Log.i(TAG, "Remove app summary (sparse group): " + r);
1724                         }
1725                         mCallback.removeAppProvidedSummary(r.getKey());
1726                         cacheCanceledSummary(r);
1727                     }
1728                 } else {
1729                     // Add any notifications left ungrouped
1730                     if (ungrouped.containsKey(r.getKey())) {
1731                         if (DEBUG) {
1732                             Log.i(TAG, "Aggregate ungrouped (sparse group): " + r);
1733                         }
1734                         mCallback.addAutoGroup(r.getKey(), fullAggregateGroupKey.toString(), true);
1735                         aggregatedNotificationsAttrs.put(r.getKey(),ungrouped.get(r.getKey()));
1736                     }
1737                 }
1738             }
1739 
1740             mAggregatedNotifications.put(fullAggregateGroupKey, aggregatedNotificationsAttrs);
1741             // add/update aggregate summary
1742             updateAggregateAppGroup(fullAggregateGroupKey, triggeringKey, hasSummary,
1743                     sectioner.mSummaryId);
1744 
1745             //cleanup mUngroupedAbuseNotifications
1746             mUngroupedAbuseNotifications.remove(fullAggregateGroupKey);
1747         }
1748     }
1749 
1750     @VisibleForTesting
getSparseGroups( final FullyQualifiedGroupKey fullAggregateGroupKey, final List<NotificationRecord> notificationList, final Map<String, NotificationRecord> summaryByGroupKey, final NotificationSectioner sectioner)1751     protected ArrayMap<String, NotificationRecord> getSparseGroups(
1752             final FullyQualifiedGroupKey fullAggregateGroupKey,
1753             final List<NotificationRecord> notificationList,
1754             final Map<String, NotificationRecord> summaryByGroupKey,
1755             final NotificationSectioner sectioner) {
1756         ArrayMap<String, NotificationRecord> sparseGroups = new ArrayMap<>();
1757         for (NotificationRecord summary : summaryByGroupKey.values()) {
1758             if (summary != null && sectioner.isInSection(summary)) {
1759                 if (summary.getSbn().getPackageName().equalsIgnoreCase(fullAggregateGroupKey.pkg)
1760                         && summary.getUserId() == fullAggregateGroupKey.userId
1761                         && summary.getSbn().isAppGroup()
1762                         && !summary.getGroupKey().equals(fullAggregateGroupKey.toString())) {
1763                     int numChildren = getNumChildrenForGroupWithSection(summary.getSbn().getGroup(),
1764                             notificationList, sectioner);
1765                     if (numChildren > 0 && numChildren < MIN_CHILD_COUNT_TO_AVOID_FORCE_GROUPING) {
1766                         sparseGroups.put(summary.getGroupKey(), summary);
1767                     }
1768                 }
1769             }
1770         }
1771         return sparseGroups;
1772     }
1773 
1774     /**
1775      *  Get the number of children of a group if all match a certain section.
1776      *  Used for force grouping sparse groups, where the summary may match a section but the
1777      *  child notifications do not: ie. conversations
1778      *
1779      * @param groupKey the group key (name)
1780      * @param notificationList all notifications list
1781      * @param sectioner the section to match
1782      * @return number of children in that group or -1 if section does not match
1783      */
getNumChildrenForGroupWithSection(final String groupKey, final List<NotificationRecord> notificationList, final NotificationSectioner sectioner)1784     private int getNumChildrenForGroupWithSection(final String groupKey,
1785             final List<NotificationRecord> notificationList,
1786             final NotificationSectioner sectioner) {
1787         int numChildren = 0;
1788         for (NotificationRecord r : notificationList) {
1789             if (!r.getNotification().isGroupSummary() && groupKey.equals(r.getSbn().getGroup())) {
1790                 NotificationSectioner childSection = getSection(r);
1791                 if (childSection == null || childSection != sectioner) {
1792                     if (DEBUG) {
1793                         Slog.i(TAG,
1794                                 "getNumChildrenForGroupWithSection skip because invalid section: "
1795                                     + groupKey + " r: " + r);
1796                     }
1797                     return -1;
1798                 } else {
1799                     numChildren++;
1800                 }
1801             }
1802         }
1803 
1804         if (DEBUG) {
1805             Slog.i(TAG,
1806                     "getNumChildrenForGroupWithSection " + groupKey + " numChild: " + numChildren);
1807         }
1808         return numChildren;
1809     }
1810 
1811     @GuardedBy("mAggregatedNotifications")
cacheCanceledSummary(NotificationRecord record)1812     private void cacheCanceledSummary(NotificationRecord record) {
1813         final FullyQualifiedGroupKey groupKey = new FullyQualifiedGroupKey(record.getUserId(),
1814                 record.getSbn().getPackageName(), record.getNotification().getGroup());
1815         mCanceledSummaries.put(groupKey, new CachedSummary(
1816                 record.getSbn().getId(),
1817                 record.getSbn().getTag(),
1818                 record.getNotification().getGroup(),
1819                 record.getKey(),
1820                 record.getNotification().deleteIntent));
1821     }
1822 
1823     @GuardedBy("mAggregatedNotifications")
maybeClearCanceledSummariesCache(String pkgName, int userId, String groupName, List<NotificationRecord> notificationList, boolean sendSummaryDelete)1824     private void maybeClearCanceledSummariesCache(String pkgName, int userId,
1825             String groupName, List<NotificationRecord> notificationList,
1826             boolean sendSummaryDelete) {
1827         final FullyQualifiedGroupKey findKey = new FullyQualifiedGroupKey(userId, pkgName,
1828                 groupName);
1829         CachedSummary summary = mCanceledSummaries.get(findKey);
1830         // Check if any notifications from original group remain
1831         if (summary != null) {
1832             if (DEBUG) {
1833                 Log.i(TAG, "Try removing cached summary: " + summary);
1834             }
1835             boolean stillHasChildren = false;
1836             //TODO (b/349072751): track grouping state in GroupHelper -> do not use notificationList
1837             for (NotificationRecord r : notificationList) {
1838                 if (summary.originalGroupKey.equals(r.getNotification().getGroup())
1839                     && r.getUser().getIdentifier() == userId
1840                     && r.getSbn().getPackageName().equals(pkgName)) {
1841                     stillHasChildren = true;
1842                     break;
1843                 }
1844             }
1845             if (!stillHasChildren) {
1846                 removeCachedSummary(pkgName, userId, summary);
1847                 if (sendSummaryDelete && summary.deleteIntent != null) {
1848                     mCallback.sendAppProvidedSummaryDeleteIntent(pkgName, summary.deleteIntent);
1849                 }
1850             }
1851         }
1852     }
1853 
1854     @VisibleForTesting
1855     @GuardedBy("mAggregatedNotifications")
findCanceledSummary(String pkgName, String tag, int id, int userId)1856     protected CachedSummary findCanceledSummary(String pkgName, String tag, int id, int userId) {
1857         for (FullyQualifiedGroupKey key: mCanceledSummaries.keySet()) {
1858             if (pkgName.equals(key.pkg) && userId == key.userId) {
1859                 CachedSummary summary = mCanceledSummaries.get(key);
1860                 if (summary != null && summary.id == id && TextUtils.equals(tag, summary.tag)) {
1861                     return summary;
1862                 }
1863             }
1864         }
1865         return null;
1866     }
1867 
1868     @VisibleForTesting
1869     @GuardedBy("mAggregatedNotifications")
findCanceledSummary(String pkgName, String tag, int id, int userId, String groupName)1870     protected CachedSummary findCanceledSummary(String pkgName, String tag, int id, int userId,
1871             String groupName) {
1872         final FullyQualifiedGroupKey findKey = new FullyQualifiedGroupKey(userId, pkgName,
1873                 groupName);
1874         CachedSummary summary = mCanceledSummaries.get(findKey);
1875         if (summary != null && summary.id == id && TextUtils.equals(tag, summary.tag)) {
1876             return summary;
1877         } else {
1878             return null;
1879         }
1880     }
1881 
1882     @GuardedBy("mAggregatedNotifications")
removeCachedSummary(String pkgName, int userId, CachedSummary summary)1883     private void removeCachedSummary(String pkgName, int userId, CachedSummary summary) {
1884         final FullyQualifiedGroupKey key = new FullyQualifiedGroupKey(userId, pkgName,
1885                 summary.originalGroupKey);
1886         mCanceledSummaries.remove(key);
1887     }
1888 
isUpdateForCanceledSummary(final NotificationRecord record)1889     protected boolean isUpdateForCanceledSummary(final NotificationRecord record) {
1890         synchronized (mAggregatedNotifications) {
1891             if (record.getSbn().isAppGroup() && record.getNotification().isGroupSummary()) {
1892                 CachedSummary cachedSummary = findCanceledSummary(record.getSbn().getPackageName(),
1893                         record.getSbn().getTag(), record.getSbn().getId(), record.getUserId(),
1894                         record.getNotification().getGroup());
1895                 return cachedSummary != null;
1896             }
1897             return false;
1898         }
1899     }
1900 
1901     /**
1902      * Cancels the original group's children when an app cancels a summary that was 'maybe'
1903      * previously removed due to forced grouping of a "sparse group".
1904      *
1905      * @param pkgName packageName
1906      * @param tag original summary notification tag
1907      * @param id original summary notification id
1908      * @param userId original summary userId
1909      */
1910     @FlaggedApi(Flags.FLAG_NOTIFICATION_FORCE_GROUP_SINGLETONS)
maybeCancelGroupChildrenForCanceledSummary(String pkgName, String tag, int id, int userId, int cancelReason)1911     public void maybeCancelGroupChildrenForCanceledSummary(String pkgName, String tag, int id,
1912             int userId, int cancelReason) {
1913         synchronized (mAggregatedNotifications) {
1914             final CachedSummary summary = findCanceledSummary(pkgName, tag, id, userId);
1915             if (summary != null) {
1916                 if (DEBUG) {
1917                     Log.i(TAG, "Found cached summary: " + summary.key);
1918                 }
1919                 mCallback.removeNotificationFromCanceledGroup(userId, pkgName,
1920                         summary.originalGroupKey, cancelReason);
1921                 removeCachedSummary(pkgName, userId, summary);
1922             }
1923         }
1924     }
1925 
getSection(final NotificationRecord record)1926     static NotificationSectioner getSection(final NotificationRecord record) {
1927         for (NotificationSectioner sectioner: NOTIFICATION_SHADE_SECTIONS) {
1928             if (sectioner.isInSection(record)) {
1929                 return sectioner;
1930             }
1931         }
1932         return null;
1933     }
1934 
FullyQualifiedGroupKey(int userId, String pkg, String groupName)1935     record FullyQualifiedGroupKey(int userId, String pkg, String groupName) {
1936         FullyQualifiedGroupKey(int userId, String pkg, @Nullable NotificationSectioner sectioner) {
1937             this(userId, pkg, AGGREGATE_GROUP_KEY + (sectioner != null ? sectioner.mName : ""));
1938         }
1939 
1940         static FullyQualifiedGroupKey forRecord(NotificationRecord record,
1941                 @Nullable NotificationSectioner sectioner) {
1942             return new FullyQualifiedGroupKey(record.getUserId(), record.getSbn().getPackageName(),
1943                     sectioner);
1944         }
1945 
1946         @Override
1947         public String toString() {
1948             return userId + "|" + pkg + "|" + "g:" + groupName;
1949         }
1950     }
1951 
dump(PrintWriter pw, String prefix)1952     protected void dump(PrintWriter pw, String prefix) {
1953         synchronized (mAggregatedNotifications) {
1954             if (!mUngroupedAbuseNotifications.isEmpty()) {
1955                 pw.println(prefix + "Ungrouped notifications:");
1956                 for (FullyQualifiedGroupKey groupKey: mUngroupedAbuseNotifications.keySet()) {
1957                     if (!mUngroupedAbuseNotifications.getOrDefault(groupKey, new ArrayMap<>())
1958                             .isEmpty()) {
1959                         pw.println(prefix + prefix + groupKey.toString());
1960                         for (String notifKey : mUngroupedAbuseNotifications.get(groupKey)
1961                                 .keySet()) {
1962                             pw.println(prefix + prefix + prefix + notifKey);
1963                         }
1964                     }
1965                 }
1966                 pw.println("");
1967             }
1968 
1969             if (!mAggregatedNotifications.isEmpty()) {
1970                 pw.println(prefix + "Autogrouped notifications:");
1971                 for (FullyQualifiedGroupKey groupKey: mAggregatedNotifications.keySet()) {
1972                     if (!mAggregatedNotifications.getOrDefault(groupKey, new ArrayMap<>())
1973                             .isEmpty()) {
1974                         pw.println(prefix + prefix + groupKey.toString());
1975                         for (String notifKey : mAggregatedNotifications.get(groupKey).keySet()) {
1976                             pw.println(prefix + prefix + prefix + notifKey);
1977                         }
1978                     }
1979                 }
1980                 pw.println("");
1981             }
1982 
1983             if (!mCanceledSummaries.isEmpty()) {
1984                 pw.println(prefix + "Cached canceled summaries:");
1985                 for (CachedSummary summary: mCanceledSummaries.values()) {
1986                     pw.println(prefix + prefix + prefix + summary.key + " -> "
1987                             + summary.originalGroupKey);
1988                 }
1989                 pw.println("");
1990             }
1991         }
1992     }
1993 
1994     protected static class NotificationSectioner {
1995         final String mName;
1996         final int mSummaryId;
1997         private final Predicate<NotificationRecord> mSectionChecker;
1998 
NotificationSectioner(String name, int summaryId, Predicate<NotificationRecord> sectionChecker)1999         private NotificationSectioner(String name, int summaryId,
2000                 Predicate<NotificationRecord> sectionChecker) {
2001             mName = name;
2002             mSummaryId = summaryId;
2003             mSectionChecker = sectionChecker;
2004         }
2005 
isInSection(final NotificationRecord record)2006         boolean isInSection(final NotificationRecord record) {
2007             return isNotificationGroupable(record) && mSectionChecker.test(record);
2008         }
2009 
isNotificationGroupable(final NotificationRecord record)2010         private boolean isNotificationGroupable(final NotificationRecord record) {
2011             if (!Flags.notificationForceGroupConversations()) {
2012                 if (record.isConversation()) {
2013                     return false;
2014                 }
2015             }
2016 
2017             Notification notification = record.getSbn().getNotification();
2018             boolean isColorizedFGS = notification.isForegroundService()
2019                 && notification.isColorized()
2020                 && record.getImportance() > NotificationManager.IMPORTANCE_MIN;
2021             boolean isCall = record.getImportance() > NotificationManager.IMPORTANCE_MIN
2022                 && notification.isStyle(Notification.CallStyle.class);
2023             if (isColorizedFGS || isCall) {
2024                 return false;
2025             }
2026 
2027             if (record.getSbn().getNotification().isMediaNotification()) {
2028                 return false;
2029             }
2030 
2031             return true;
2032         }
2033     }
2034 
CachedSummary(int id, String tag, String originalGroupKey, String key, @Nullable PendingIntent deleteIntent)2035     record CachedSummary(int id, String tag, String originalGroupKey, String key,
2036                          @Nullable PendingIntent deleteIntent) { }
2037 
2038     protected static class NotificationAttributes {
2039         public final int flags;
2040         public final int iconColor;
2041         public final Icon icon;
2042         public final int visibility;
2043         public final int groupAlertBehavior;
2044         public final String channelId;
2045 
NotificationAttributes(int flags, Icon icon, int iconColor, int visibility, int groupAlertBehavior, String channelId)2046         public NotificationAttributes(int flags, Icon icon, int iconColor, int visibility,
2047                 int groupAlertBehavior, String channelId) {
2048             this.flags = flags;
2049             this.icon = icon;
2050             this.iconColor = iconColor;
2051             this.visibility = visibility;
2052             this.groupAlertBehavior = groupAlertBehavior;
2053             this.channelId = channelId;
2054         }
2055 
NotificationAttributes(@onNull NotificationAttributes attr)2056         public NotificationAttributes(@NonNull NotificationAttributes attr) {
2057             this.flags = attr.flags;
2058             this.icon = attr.icon;
2059             this.iconColor = attr.iconColor;
2060             this.visibility = attr.visibility;
2061             this.groupAlertBehavior = attr.groupAlertBehavior;
2062             this.channelId = attr.channelId;
2063         }
2064 
2065         @Override
equals(Object o)2066         public boolean equals(Object o) {
2067             if (this == o) {
2068                 return true;
2069             }
2070             if (!(o instanceof NotificationAttributes that)) {
2071                 return false;
2072             }
2073             return flags == that.flags && iconColor == that.iconColor && icon.sameAs(that.icon)
2074                     && visibility == that.visibility
2075                     && groupAlertBehavior == that.groupAlertBehavior
2076                     && channelId.equals(that.channelId);
2077         }
2078 
2079         @Override
hashCode()2080         public int hashCode() {
2081             return Objects.hash(flags, iconColor, icon, visibility, groupAlertBehavior, channelId);
2082         }
2083 
2084         @Override
toString()2085         public String toString() {
2086             return "NotificationAttributes: flags: " + flags + " icon: " + icon + " color: "
2087                     + iconColor + " vis: " + visibility + " groupAlertBehavior: "
2088                     + groupAlertBehavior + " channelId: " + channelId;
2089         }
2090     }
2091 
2092     protected interface Callback {
addAutoGroup(String key, String groupName, boolean requestSort)2093         void addAutoGroup(String key, String groupName, boolean requestSort);
removeAutoGroup(String key)2094         void removeAutoGroup(String key);
2095 
addAutoGroupSummary(int userId, String pkg, String triggeringKey, String groupName, int summaryId, NotificationAttributes summaryAttr)2096         void addAutoGroupSummary(int userId, String pkg, String triggeringKey, String groupName,
2097                 int summaryId, NotificationAttributes summaryAttr);
removeAutoGroupSummary(int user, String pkg, String groupKey)2098         void removeAutoGroupSummary(int user, String pkg, String groupKey);
2099 
updateAutogroupSummary(int userId, String pkg, String groupKey, NotificationAttributes summaryAttr)2100         void updateAutogroupSummary(int userId, String pkg, String groupKey,
2101                 NotificationAttributes summaryAttr);
2102 
2103         // New callbacks for API abuse grouping
removeAppProvidedSummary(String key)2104         void removeAppProvidedSummary(String key);
2105 
2106         /**
2107          * Send a cached summary's deleteIntent, when the last of its original children is removed.
2108          *
2109          * <p>While technically the group summary was "canceled" much earlier (because it was the
2110          * summary of a sparse group and its children got reparented), the posting package expected
2111          * the summary's deleteIntent to fire when the summary is auto-dismissed.
2112          */
sendAppProvidedSummaryDeleteIntent(String pkg, PendingIntent deleteIntent)2113         void sendAppProvidedSummaryDeleteIntent(String pkg, PendingIntent deleteIntent);
2114 
removeNotificationFromCanceledGroup(int userId, String pkg, String groupKey, int cancelReason)2115         void removeNotificationFromCanceledGroup(int userId, String pkg, String groupKey,
2116                 int cancelReason);
2117 
2118         /**
2119          * Cancels the group summary of a notification that was regrouped because of classification
2120          *  (bundling). Only cancels if the summary is the last notification of the original group.
2121          * @param triggeringKey the triggering child notification key
2122          * @param groupKey the original group key
2123          * @return the canceled group summary or null if the summary was not canceled
2124          */
2125         @Nullable
removeAppProvidedSummaryOnClassification(String triggeringKey, @Nullable String groupKey)2126         NotificationRecord removeAppProvidedSummaryOnClassification(String triggeringKey,
2127                 @Nullable String groupKey);
2128     }
2129 }
2130