1 /* 2 * Copyright (C) 2015 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License 15 */ 16 17 package com.android.systemui.statusbar.phone; 18 19 import android.service.notification.StatusBarNotification; 20 import android.support.annotation.Nullable; 21 import android.util.Log; 22 23 import com.android.systemui.statusbar.ExpandableNotificationRow; 24 import com.android.systemui.statusbar.NotificationData; 25 import com.android.systemui.statusbar.StatusBarState; 26 import com.android.systemui.statusbar.policy.HeadsUpManager; 27 import com.android.systemui.statusbar.policy.OnHeadsUpChangedListener; 28 29 import java.io.FileDescriptor; 30 import java.io.PrintWriter; 31 import java.util.ArrayList; 32 import java.util.HashMap; 33 import java.util.Iterator; 34 import java.util.Map; 35 36 /** 37 * A class to handle notifications and their corresponding groups. 38 */ 39 public class NotificationGroupManager implements OnHeadsUpChangedListener { 40 41 private static final String TAG = "NotificationGroupManager"; 42 private final HashMap<String, NotificationGroup> mGroupMap = new HashMap<>(); 43 private OnGroupChangeListener mListener; 44 private int mBarState = -1; 45 private HashMap<String, StatusBarNotification> mIsolatedEntries = new HashMap<>(); 46 private HeadsUpManager mHeadsUpManager; 47 private boolean mIsUpdatingUnchangedGroup; 48 49 public void setOnGroupChangeListener(OnGroupChangeListener listener) { 50 mListener = listener; 51 } 52 53 public boolean isGroupExpanded(StatusBarNotification sbn) { 54 NotificationGroup group = mGroupMap.get(getGroupKey(sbn)); 55 if (group == null) { 56 return false; 57 } 58 return group.expanded; 59 } 60 61 public void setGroupExpanded(StatusBarNotification sbn, boolean expanded) { 62 NotificationGroup group = mGroupMap.get(getGroupKey(sbn)); 63 if (group == null) { 64 return; 65 } 66 setGroupExpanded(group, expanded); 67 } 68 69 private void setGroupExpanded(NotificationGroup group, boolean expanded) { 70 group.expanded = expanded; 71 if (group.summary != null) { 72 mListener.onGroupExpansionChanged(group.summary.row, expanded); 73 } 74 } 75 76 public void onEntryRemoved(NotificationData.Entry removed) { 77 onEntryRemovedInternal(removed, removed.notification); 78 mIsolatedEntries.remove(removed.key); 79 } 80 81 /** 82 * An entry was removed. 83 * 84 * @param removed the removed entry 85 * @param sbn the notification the entry has, which doesn't need to be the same as it's internal 86 * notification 87 */ 88 private void onEntryRemovedInternal(NotificationData.Entry removed, 89 final StatusBarNotification sbn) { 90 String groupKey = getGroupKey(sbn); 91 final NotificationGroup group = mGroupMap.get(groupKey); 92 if (group == null) { 93 // When an app posts 2 different notifications as summary of the same group, then a 94 // cancellation of the first notification removes this group. 95 // This situation is not supported and we will not allow such notifications anymore in 96 // the close future. See b/23676310 for reference. 97 return; 98 } 99 if (isGroupChild(sbn)) { 100 group.children.remove(removed.key); 101 } else { 102 group.summary = null; 103 } 104 updateSuppression(group); 105 if (group.children.isEmpty()) { 106 if (group.summary == null) { 107 mGroupMap.remove(groupKey); 108 } 109 } 110 } 111 112 public void onEntryAdded(final NotificationData.Entry added) { 113 if (added.row.isRemoved()) { 114 added.setDebugThrowable(new Throwable()); 115 } 116 final StatusBarNotification sbn = added.notification; 117 boolean isGroupChild = isGroupChild(sbn); 118 String groupKey = getGroupKey(sbn); 119 NotificationGroup group = mGroupMap.get(groupKey); 120 if (group == null) { 121 group = new NotificationGroup(); 122 mGroupMap.put(groupKey, group); 123 } 124 if (isGroupChild) { 125 NotificationData.Entry existing = group.children.get(added.key); 126 if (existing != null && existing != added) { 127 Throwable existingThrowable = existing.getDebugThrowable(); 128 Log.wtf(TAG, "Inconsistent entries found with the same key " + added.key 129 + "existing removed: " + existing.row.isRemoved() 130 + (existingThrowable != null 131 ? Log.getStackTraceString(existingThrowable) + "\n": "") 132 + " added removed" + added.row.isRemoved() 133 , new Throwable()); 134 } 135 group.children.put(added.key, added); 136 updateSuppression(group); 137 } else { 138 group.summary = added; 139 group.expanded = added.row.areChildrenExpanded(); 140 updateSuppression(group); 141 if (!group.children.isEmpty()) { 142 ArrayList<NotificationData.Entry> childrenCopy 143 = new ArrayList<>(group.children.values()); 144 for (NotificationData.Entry child : childrenCopy) { 145 onEntryBecomingChild(child); 146 } 147 mListener.onGroupCreatedFromChildren(group); 148 } 149 } 150 } 151 152 private void onEntryBecomingChild(NotificationData.Entry entry) { 153 if (entry.row.isHeadsUp()) { 154 onHeadsUpStateChanged(entry, true); 155 } 156 } 157 158 private void updateSuppression(NotificationGroup group) { 159 if (group == null) { 160 return; 161 } 162 boolean prevSuppressed = group.suppressed; 163 group.suppressed = group.summary != null && !group.expanded 164 && (group.children.size() == 1 165 || (group.children.size() == 0 166 && group.summary.notification.getNotification().isGroupSummary() 167 && hasIsolatedChildren(group))); 168 if (prevSuppressed != group.suppressed) { 169 if (group.suppressed) { 170 handleSuppressedSummaryHeadsUpped(group.summary); 171 } 172 if (!mIsUpdatingUnchangedGroup) { 173 mListener.onGroupsChanged(); 174 } 175 } 176 } 177 178 private boolean hasIsolatedChildren(NotificationGroup group) { 179 return getNumberOfIsolatedChildren(group.summary.notification.getGroupKey()) != 0; 180 } 181 182 private int getNumberOfIsolatedChildren(String groupKey) { 183 int count = 0; 184 for (StatusBarNotification sbn : mIsolatedEntries.values()) { 185 if (sbn.getGroupKey().equals(groupKey) && isIsolated(sbn)) { 186 count++; 187 } 188 } 189 return count; 190 } 191 192 private NotificationData.Entry getIsolatedChild(String groupKey) { 193 for (StatusBarNotification sbn : mIsolatedEntries.values()) { 194 if (sbn.getGroupKey().equals(groupKey) && isIsolated(sbn)) { 195 return mGroupMap.get(sbn.getKey()).summary; 196 } 197 } 198 return null; 199 } 200 201 public void onEntryUpdated(NotificationData.Entry entry, 202 StatusBarNotification oldNotification) { 203 String oldKey = oldNotification.getGroupKey(); 204 String newKey = entry.notification.getGroupKey(); 205 boolean groupKeysChanged = !oldKey.equals(newKey); 206 boolean wasGroupChild = isGroupChild(oldNotification); 207 boolean isGroupChild = isGroupChild(entry.notification); 208 mIsUpdatingUnchangedGroup = !groupKeysChanged && wasGroupChild == isGroupChild; 209 if (mGroupMap.get(getGroupKey(oldNotification)) != null) { 210 onEntryRemovedInternal(entry, oldNotification); 211 } 212 onEntryAdded(entry); 213 mIsUpdatingUnchangedGroup = false; 214 if (isIsolated(entry.notification)) { 215 mIsolatedEntries.put(entry.key, entry.notification); 216 if (groupKeysChanged) { 217 updateSuppression(mGroupMap.get(oldKey)); 218 updateSuppression(mGroupMap.get(newKey)); 219 } 220 } else if (!wasGroupChild && isGroupChild) { 221 onEntryBecomingChild(entry); 222 } 223 } 224 225 public boolean isSummaryOfSuppressedGroup(StatusBarNotification sbn) { 226 return isGroupSuppressed(getGroupKey(sbn)) && sbn.getNotification().isGroupSummary(); 227 } 228 229 private boolean isOnlyChild(StatusBarNotification sbn) { 230 return !sbn.getNotification().isGroupSummary() 231 && getTotalNumberOfChildren(sbn) == 1; 232 } 233 234 public boolean isOnlyChildInGroup(StatusBarNotification sbn) { 235 if (!isOnlyChild(sbn)) { 236 return false; 237 } 238 ExpandableNotificationRow logicalGroupSummary = getLogicalGroupSummary(sbn); 239 return logicalGroupSummary != null 240 && !logicalGroupSummary.getStatusBarNotification().equals(sbn); 241 } 242 243 private int getTotalNumberOfChildren(StatusBarNotification sbn) { 244 int isolatedChildren = getNumberOfIsolatedChildren(sbn.getGroupKey()); 245 NotificationGroup group = mGroupMap.get(sbn.getGroupKey()); 246 int realChildren = group != null ? group.children.size() : 0; 247 return isolatedChildren + realChildren; 248 } 249 250 private boolean isGroupSuppressed(String groupKey) { 251 NotificationGroup group = mGroupMap.get(groupKey); 252 return group != null && group.suppressed; 253 } 254 255 public void setStatusBarState(int newState) { 256 if (mBarState == newState) { 257 return; 258 } 259 mBarState = newState; 260 if (mBarState == StatusBarState.KEYGUARD) { 261 collapseAllGroups(); 262 } 263 } 264 265 public void collapseAllGroups() { 266 // Because notifications can become isolated when the group becomes suppressed it can 267 // lead to concurrent modifications while looping. We need to make a copy. 268 ArrayList<NotificationGroup> groupCopy = new ArrayList<>(mGroupMap.values()); 269 int size = groupCopy.size(); 270 for (int i = 0; i < size; i++) { 271 NotificationGroup group = groupCopy.get(i); 272 if (group.expanded) { 273 setGroupExpanded(group, false); 274 } 275 updateSuppression(group); 276 } 277 } 278 279 /** 280 * @return whether a given notification is a child in a group which has a summary 281 */ 282 public boolean isChildInGroupWithSummary(StatusBarNotification sbn) { 283 if (!isGroupChild(sbn)) { 284 return false; 285 } 286 NotificationGroup group = mGroupMap.get(getGroupKey(sbn)); 287 if (group == null || group.summary == null || group.suppressed) { 288 return false; 289 } 290 if (group.children.isEmpty()) { 291 // If the suppression of a group changes because the last child was removed, this can 292 // still be called temporarily because the child hasn't been fully removed yet. Let's 293 // make sure we still return false in that case. 294 return false; 295 } 296 return true; 297 } 298 299 /** 300 * @return whether a given notification is a summary in a group which has children 301 */ 302 public boolean isSummaryOfGroup(StatusBarNotification sbn) { 303 if (!isGroupSummary(sbn)) { 304 return false; 305 } 306 NotificationGroup group = mGroupMap.get(getGroupKey(sbn)); 307 if (group == null) { 308 return false; 309 } 310 return !group.children.isEmpty(); 311 } 312 313 /** 314 * Get the summary of a specified status bar notification. For isolated notification this return 315 * itself. 316 */ 317 public ExpandableNotificationRow getGroupSummary(StatusBarNotification sbn) { 318 return getGroupSummary(getGroupKey(sbn)); 319 } 320 321 /** 322 * Similar to {@link #getGroupSummary(StatusBarNotification)} but doesn't get the visual summary 323 * but the logical summary, i.e when a child is isolated, it still returns the summary as if 324 * it wasn't isolated. 325 */ 326 public ExpandableNotificationRow getLogicalGroupSummary( 327 StatusBarNotification sbn) { 328 return getGroupSummary(sbn.getGroupKey()); 329 } 330 331 @Nullable 332 private ExpandableNotificationRow getGroupSummary(String groupKey) { 333 NotificationGroup group = mGroupMap.get(groupKey); 334 return group == null ? null 335 : group.summary == null ? null 336 : group.summary.row; 337 } 338 339 /** @return group expansion state after toggling. */ 340 public boolean toggleGroupExpansion(StatusBarNotification sbn) { 341 NotificationGroup group = mGroupMap.get(getGroupKey(sbn)); 342 if (group == null) { 343 return false; 344 } 345 setGroupExpanded(group, !group.expanded); 346 return group.expanded; 347 } 348 349 private boolean isIsolated(StatusBarNotification sbn) { 350 return mIsolatedEntries.containsKey(sbn.getKey()); 351 } 352 353 private boolean isGroupSummary(StatusBarNotification sbn) { 354 if (isIsolated(sbn)) { 355 return true; 356 } 357 return sbn.getNotification().isGroupSummary(); 358 } 359 360 private boolean isGroupChild(StatusBarNotification sbn) { 361 if (isIsolated(sbn)) { 362 return false; 363 } 364 return sbn.isGroup() && !sbn.getNotification().isGroupSummary(); 365 } 366 367 private String getGroupKey(StatusBarNotification sbn) { 368 if (isIsolated(sbn)) { 369 return sbn.getKey(); 370 } 371 return sbn.getGroupKey(); 372 } 373 374 @Override 375 public void onHeadsUpPinnedModeChanged(boolean inPinnedMode) { 376 } 377 378 @Override 379 public void onHeadsUpPinned(ExpandableNotificationRow headsUp) { 380 } 381 382 @Override 383 public void onHeadsUpUnPinned(ExpandableNotificationRow headsUp) { 384 } 385 386 @Override 387 public void onHeadsUpStateChanged(NotificationData.Entry entry, boolean isHeadsUp) { 388 final StatusBarNotification sbn = entry.notification; 389 if (entry.row.isHeadsUp()) { 390 if (shouldIsolate(sbn)) { 391 // We will be isolated now, so lets update the groups 392 onEntryRemovedInternal(entry, entry.notification); 393 394 mIsolatedEntries.put(sbn.getKey(), sbn); 395 396 onEntryAdded(entry); 397 // We also need to update the suppression of the old group, because this call comes 398 // even before the groupManager knows about the notification at all. 399 // When the notification gets added afterwards it is already isolated and therefore 400 // it doesn't lead to an update. 401 updateSuppression(mGroupMap.get(entry.notification.getGroupKey())); 402 mListener.onGroupsChanged(); 403 } else { 404 handleSuppressedSummaryHeadsUpped(entry); 405 } 406 } else { 407 if (mIsolatedEntries.containsKey(sbn.getKey())) { 408 // not isolated anymore, we need to update the groups 409 onEntryRemovedInternal(entry, entry.notification); 410 mIsolatedEntries.remove(sbn.getKey()); 411 onEntryAdded(entry); 412 mListener.onGroupsChanged(); 413 } 414 } 415 } 416 417 private void handleSuppressedSummaryHeadsUpped(NotificationData.Entry entry) { 418 StatusBarNotification sbn = entry.notification; 419 if (!isGroupSuppressed(sbn.getGroupKey()) 420 || !sbn.getNotification().isGroupSummary() 421 || !entry.row.isHeadsUp()) { 422 return; 423 } 424 // The parent of a suppressed group got huned, lets hun the child! 425 NotificationGroup notificationGroup = mGroupMap.get(sbn.getGroupKey()); 426 if (notificationGroup != null) { 427 Iterator<NotificationData.Entry> iterator 428 = notificationGroup.children.values().iterator(); 429 NotificationData.Entry child = iterator.hasNext() ? iterator.next() : null; 430 if (child == null) { 431 child = getIsolatedChild(sbn.getGroupKey()); 432 } 433 if (child != null) { 434 if (child.row.keepInParent() || child.row.isRemoved() || child.row.isDismissed()) { 435 // the notification is actually already removed, no need to do heads-up on it. 436 return; 437 } 438 if (mHeadsUpManager.isHeadsUp(child.key)) { 439 mHeadsUpManager.updateNotification(child, true); 440 } else { 441 mHeadsUpManager.showNotification(child); 442 } 443 } 444 } 445 mHeadsUpManager.releaseImmediately(entry.key); 446 } 447 448 private boolean shouldIsolate(StatusBarNotification sbn) { 449 NotificationGroup notificationGroup = mGroupMap.get(sbn.getGroupKey()); 450 return (sbn.isGroup() && !sbn.getNotification().isGroupSummary()) 451 && (sbn.getNotification().fullScreenIntent != null 452 || notificationGroup == null 453 || !notificationGroup.expanded 454 || isGroupNotFullyVisible(notificationGroup)); 455 } 456 457 private boolean isGroupNotFullyVisible(NotificationGroup notificationGroup) { 458 return notificationGroup.summary == null 459 || notificationGroup.summary.row.getClipTopAmount() > 0 460 || notificationGroup.summary.row.getTranslationY() < 0; 461 } 462 463 public void setHeadsUpManager(HeadsUpManager headsUpManager) { 464 mHeadsUpManager = headsUpManager; 465 } 466 467 public void dump(FileDescriptor fd, PrintWriter pw, String[] args) { 468 pw.println("GroupManager state:"); 469 pw.println(" number of groups: " + mGroupMap.size()); 470 for (Map.Entry<String, NotificationGroup> entry : mGroupMap.entrySet()) { 471 pw.println("\n key: " + entry.getKey()); pw.println(entry.getValue()); 472 } 473 pw.println("\n isolated entries: " + mIsolatedEntries.size()); 474 for (Map.Entry<String, StatusBarNotification> entry : mIsolatedEntries.entrySet()) { 475 pw.print(" "); pw.print(entry.getKey()); 476 pw.print(", "); pw.println(entry.getValue()); 477 } 478 } 479 480 public static class NotificationGroup { 481 public final HashMap<String, NotificationData.Entry> children = new HashMap<>(); 482 public NotificationData.Entry summary; 483 public boolean expanded; 484 /** 485 * Is this notification group suppressed, i.e its summary is hidden 486 */ 487 public boolean suppressed; 488 489 @Override 490 public String toString() { 491 String result = " summary:\n " 492 + (summary != null ? summary.notification : "null") 493 + (summary != null && summary.getDebugThrowable() != null 494 ? Log.getStackTraceString(summary.getDebugThrowable()) 495 : ""); 496 result += "\n children size: " + children.size(); 497 for (NotificationData.Entry child : children.values()) { 498 result += "\n " + child.notification 499 + (child.getDebugThrowable() != null 500 ? Log.getStackTraceString(child.getDebugThrowable()) 501 : ""); 502 } 503 return result; 504 } 505 } 506 507 public interface OnGroupChangeListener { 508 /** 509 * The expansion of a group has changed. 510 * 511 * @param changedRow the row for which the expansion has changed, which is also the summary 512 * @param expanded a boolean indicating the new expanded state 513 */ 514 void onGroupExpansionChanged(ExpandableNotificationRow changedRow, boolean expanded); 515 516 /** 517 * A group of children just received a summary notification and should therefore become 518 * children of it. 519 * 520 * @param group the group created 521 */ 522 void onGroupCreatedFromChildren(NotificationGroup group); 523 524 /** 525 * The groups have changed. This can happen if the isolation of a child has changes or if a 526 * group became suppressed / unsuppressed 527 */ 528 void onGroupsChanged(); 529 } 530 } 531