1 /* <lambda>null2 * Copyright (C) 2019 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.systemui.statusbar.notification.stack 17 18 import android.annotation.ColorInt 19 import android.annotation.IntDef 20 import android.annotation.LayoutRes 21 import android.content.Intent 22 import android.provider.Settings 23 import android.util.Log 24 import android.view.LayoutInflater 25 import android.view.View 26 import com.android.internal.annotations.VisibleForTesting 27 import com.android.systemui.R 28 import com.android.systemui.media.KeyguardMediaController 29 import com.android.systemui.plugins.ActivityStarter 30 import com.android.systemui.plugins.statusbar.StatusBarStateController 31 import com.android.systemui.statusbar.StatusBarState 32 import com.android.systemui.statusbar.notification.NotificationSectionsFeatureManager 33 import com.android.systemui.statusbar.notification.people.DataListener 34 import com.android.systemui.statusbar.notification.people.PeopleHubViewAdapter 35 import com.android.systemui.statusbar.notification.people.PeopleHubViewBoundary 36 import com.android.systemui.statusbar.notification.people.PersonViewModel 37 import com.android.systemui.statusbar.notification.people.Subscription 38 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow 39 import com.android.systemui.statusbar.notification.row.ExpandableView 40 import com.android.systemui.statusbar.notification.row.StackScrollerDecorView 41 import com.android.systemui.statusbar.notification.stack.StackScrollAlgorithm.SectionProvider 42 import com.android.systemui.statusbar.policy.ConfigurationController 43 import com.android.systemui.util.children 44 import com.android.systemui.util.takeUntil 45 import com.android.systemui.util.foldToSparseArray 46 import javax.inject.Inject 47 48 /** 49 * Manages the boundaries of the two notification sections (high priority and low priority). Also 50 * shows/hides the headers for those sections where appropriate. 51 * 52 * TODO: Move remaining sections logic from NSSL into this class. 53 */ 54 class NotificationSectionsManager @Inject internal constructor( 55 private val activityStarter: ActivityStarter, 56 private val statusBarStateController: StatusBarStateController, 57 private val configurationController: ConfigurationController, 58 private val peopleHubViewAdapter: PeopleHubViewAdapter, 59 private val keyguardMediaController: KeyguardMediaController, 60 private val sectionsFeatureManager: NotificationSectionsFeatureManager, 61 private val logger: NotificationSectionsLogger 62 ) : SectionProvider { 63 64 private val configurationListener = object : ConfigurationController.ConfigurationListener { 65 override fun onLocaleListChanged() { 66 reinflateViews(LayoutInflater.from(parent.context)) 67 } 68 } 69 70 private val peopleHubViewBoundary: PeopleHubViewBoundary = object : PeopleHubViewBoundary { 71 override fun setVisible(isVisible: Boolean) { 72 if (peopleHubVisible != isVisible) { 73 peopleHubVisible = isVisible 74 if (initialized) { 75 updateSectionBoundaries("PeopleHub visibility changed") 76 } 77 } 78 } 79 80 override val associatedViewForClickAnimation: View 81 get() = peopleHeaderView!! 82 83 override val personViewAdapters: Sequence<DataListener<PersonViewModel?>> 84 get() = peopleHeaderView!!.personViewAdapters 85 } 86 87 private lateinit var parent: NotificationStackScrollLayout 88 private var initialized = false 89 private var onClearSilentNotifsClickListener: View.OnClickListener? = null 90 91 @get:VisibleForTesting 92 var silentHeaderView: SectionHeaderView? = null 93 private set 94 95 @get:VisibleForTesting 96 var alertingHeaderView: SectionHeaderView? = null 97 private set 98 99 @get:VisibleForTesting 100 var incomingHeaderView: SectionHeaderView? = null 101 private set 102 103 @get:VisibleForTesting 104 var peopleHeaderView: PeopleHubView? = null 105 private set 106 107 @set:VisibleForTesting 108 var peopleHubVisible = false 109 private var peopleHubSubscription: Subscription? = null 110 111 @get:VisibleForTesting 112 var mediaControlsView: MediaHeaderView? = null 113 private set 114 115 /** Must be called before use. */ 116 fun initialize(parent: NotificationStackScrollLayout, layoutInflater: LayoutInflater) { 117 check(!initialized) { "NotificationSectionsManager already initialized" } 118 initialized = true 119 this.parent = parent 120 reinflateViews(layoutInflater) 121 configurationController.addCallback(configurationListener) 122 } 123 124 private fun <T : ExpandableView> reinflateView( 125 view: T?, 126 layoutInflater: LayoutInflater, 127 @LayoutRes layoutResId: Int 128 ): T { 129 var oldPos = -1 130 view?.let { 131 view.transientContainer?.removeView(view) 132 if (view.parent === parent) { 133 oldPos = parent.indexOfChild(view) 134 parent.removeView(view) 135 } 136 } 137 val inflated = layoutInflater.inflate(layoutResId, parent, false) as T 138 if (oldPos != -1) { 139 parent.addView(inflated, oldPos) 140 } 141 return inflated 142 } 143 144 fun createSectionsForBuckets(): Array<NotificationSection> = 145 sectionsFeatureManager.getNotificationBuckets() 146 .map { NotificationSection(parent, it) } 147 .toTypedArray() 148 149 /** 150 * Reinflates the entire notification header, including all decoration views. 151 */ 152 fun reinflateViews(layoutInflater: LayoutInflater) { 153 silentHeaderView = reinflateView( 154 silentHeaderView, layoutInflater, R.layout.status_bar_notification_section_header 155 ).apply { 156 setHeaderText(R.string.notification_section_header_gentle) 157 setOnHeaderClickListener { onGentleHeaderClick() } 158 setOnClearAllClickListener { onClearGentleNotifsClick(it) } 159 } 160 alertingHeaderView = reinflateView( 161 alertingHeaderView, layoutInflater, R.layout.status_bar_notification_section_header 162 ).apply { 163 setHeaderText(R.string.notification_section_header_alerting) 164 setOnHeaderClickListener { onGentleHeaderClick() } 165 } 166 peopleHubSubscription?.unsubscribe() 167 peopleHubSubscription = null 168 peopleHeaderView = reinflateView(peopleHeaderView, layoutInflater, R.layout.people_strip) 169 if (ENABLE_SNOOZED_CONVERSATION_HUB) { 170 peopleHubSubscription = peopleHubViewAdapter.bindView(peopleHubViewBoundary) 171 } 172 incomingHeaderView = reinflateView( 173 incomingHeaderView, layoutInflater, R.layout.status_bar_notification_section_header 174 ).apply { 175 setHeaderText(R.string.notification_section_header_incoming) 176 setOnHeaderClickListener { onGentleHeaderClick() } 177 } 178 mediaControlsView = 179 reinflateView(mediaControlsView, layoutInflater, R.layout.keyguard_media_header) 180 .also(keyguardMediaController::attach) 181 } 182 183 override fun beginsSection(view: View, previous: View?): Boolean = 184 view === silentHeaderView || 185 view === mediaControlsView || 186 view === peopleHeaderView || 187 view === alertingHeaderView || 188 view === incomingHeaderView || 189 getBucket(view) != getBucket(previous) 190 191 private fun getBucket(view: View?): Int? = when { 192 view === silentHeaderView -> BUCKET_SILENT 193 view === incomingHeaderView -> BUCKET_HEADS_UP 194 view === mediaControlsView -> BUCKET_MEDIA_CONTROLS 195 view === peopleHeaderView -> BUCKET_PEOPLE 196 view === alertingHeaderView -> BUCKET_ALERTING 197 view is ExpandableNotificationRow -> view.entry.bucket 198 else -> null 199 } 200 201 private fun logShadeChild(i: Int, child: View) { 202 when { 203 child === incomingHeaderView -> logger.logIncomingHeader(i) 204 child === mediaControlsView -> logger.logMediaControls(i) 205 child === peopleHeaderView -> logger.logConversationsHeader(i) 206 child === alertingHeaderView -> logger.logAlertingHeader(i) 207 child === silentHeaderView -> logger.logSilentHeader(i) 208 child !is ExpandableNotificationRow -> logger.logOther(i, child.javaClass) 209 else -> { 210 val isHeadsUp = child.isHeadsUp 211 when (child.entry.bucket) { 212 BUCKET_HEADS_UP -> logger.logHeadsUp(i, isHeadsUp) 213 BUCKET_PEOPLE -> logger.logConversation(i, isHeadsUp) 214 BUCKET_ALERTING -> logger.logAlerting(i, isHeadsUp) 215 BUCKET_SILENT -> logger.logSilent(i, isHeadsUp) 216 } 217 } 218 } 219 } 220 private fun logShadeContents() = parent.children.forEachIndexed(::logShadeChild) 221 222 private val isUsingMultipleSections: Boolean 223 get() = sectionsFeatureManager.getNumberOfBuckets() > 1 224 225 @VisibleForTesting 226 fun updateSectionBoundaries() = updateSectionBoundaries("test") 227 228 private interface SectionUpdateState<out T : ExpandableView> { 229 val header: T 230 var currentPosition: Int? 231 var targetPosition: Int? 232 fun adjustViewPosition() 233 } 234 235 private fun <T : ExpandableView> expandableViewHeaderState(header: T): SectionUpdateState<T> = 236 object : SectionUpdateState<T> { 237 override val header = header 238 override var currentPosition: Int? = null 239 override var targetPosition: Int? = null 240 241 override fun adjustViewPosition() { 242 val target = targetPosition 243 val current = currentPosition 244 if (target == null) { 245 if (current != null) { 246 parent.removeView(header) 247 } 248 } else { 249 if (current == null) { 250 // If the header is animating away, it will still have a parent, so 251 // detach it first 252 // TODO: We should really cancel the active animations here. This will 253 // happen automatically when the view's intro animation starts, but 254 // it's a fragile link. 255 header.transientContainer?.removeTransientView(header) 256 header.transientContainer = null 257 parent.addView(header, target) 258 } else { 259 parent.changeViewPosition(header, target) 260 } 261 } 262 } 263 } 264 265 private fun <T : StackScrollerDecorView> decorViewHeaderState( 266 header: T 267 ): SectionUpdateState<T> { 268 val inner = expandableViewHeaderState(header) 269 return object : SectionUpdateState<T> by inner { 270 override fun adjustViewPosition() { 271 inner.adjustViewPosition() 272 if (targetPosition != null && currentPosition == null) { 273 header.isContentVisible = true 274 } 275 } 276 } 277 } 278 279 /** 280 * Should be called whenever notifs are added, removed, or updated. Updates section boundary 281 * bookkeeping and adds/moves/removes section headers if appropriate. 282 */ 283 fun updateSectionBoundaries(reason: String) { 284 if (!isUsingMultipleSections) { 285 return 286 } 287 logger.logStartSectionUpdate(reason) 288 289 // The overall strategy here is to iterate over the current children of mParent, looking 290 // for where the sections headers are currently positioned, and where each section begins. 291 // Then, once we find the start of a new section, we track that position as the "target" for 292 // the section header, adjusted for the case where existing headers are in front of that 293 // target, but won't be once they are moved / removed after the pass has completed. 294 295 val showHeaders = statusBarStateController.state != StatusBarState.KEYGUARD 296 val usingPeopleFiltering = sectionsFeatureManager.isFilteringEnabled() 297 val usingMediaControls = sectionsFeatureManager.isMediaControlsEnabled() 298 299 val mediaState = mediaControlsView?.let(::expandableViewHeaderState) 300 val incomingState = incomingHeaderView?.let(::decorViewHeaderState) 301 val peopleState = peopleHeaderView?.let(::decorViewHeaderState) 302 val alertingState = alertingHeaderView?.let(::decorViewHeaderState) 303 val gentleState = silentHeaderView?.let(::decorViewHeaderState) 304 305 fun getSectionState(view: View): SectionUpdateState<ExpandableView>? = when { 306 view === mediaControlsView -> mediaState 307 view === incomingHeaderView -> incomingState 308 view === peopleHeaderView -> peopleState 309 view === alertingHeaderView -> alertingState 310 view === silentHeaderView -> gentleState 311 else -> null 312 } 313 314 val headersOrdered = sequenceOf( 315 mediaState, incomingState, peopleState, alertingState, gentleState 316 ).filterNotNull() 317 318 var peopleNotifsPresent = false 319 var lastNotifIndex = 0 320 var nextBucket: Int? = null 321 var inIncomingSection = false 322 323 // Iterating backwards allows for easier construction of the Incoming section, as opposed 324 // to backtracking when a discontinuity in the sections is discovered. 325 // Iterating to -1 in order to support the case where a header is at the very top of the 326 // shade. 327 for (i in parent.childCount - 1 downTo -1) { 328 val child: View? = parent.getChildAt(i) 329 330 child?.let { 331 logShadeChild(i, child) 332 // If this child is a header, update the tracked positions 333 getSectionState(child)?.let { state -> 334 state.currentPosition = i 335 // If headers that should appear above this one in the shade already have a 336 // target index, then we need to decrement them in order to account for this one 337 // being either removed, or moved below them. 338 headersOrdered.takeUntil { it === state } 339 .forEach { it.targetPosition = it.targetPosition?.minus(1) } 340 } 341 } 342 343 val row = (child as? ExpandableNotificationRow) 344 ?.takeUnless { it.visibility == View.GONE } 345 346 // Is there a section discontinuity? This usually occurs due to HUNs 347 inIncomingSection = inIncomingSection || nextBucket?.let { next -> 348 row?.entry?.bucket?.let { curr -> next < curr } 349 } == true 350 351 if (inIncomingSection) { 352 // Update the bucket to reflect that it's being placed in the Incoming section 353 row?.entry?.bucket = BUCKET_HEADS_UP 354 } 355 356 // Insert a header in front of the next row, if there's a boundary between it and this 357 // row, or if it is the topmost row. 358 val isSectionBoundary = nextBucket != null && 359 (child == null || row != null && nextBucket != row.entry.bucket) 360 if (isSectionBoundary && showHeaders) { 361 when (nextBucket) { 362 BUCKET_HEADS_UP -> incomingState?.targetPosition = i + 1 363 BUCKET_PEOPLE -> peopleState?.targetPosition = i + 1 364 BUCKET_ALERTING -> alertingState?.targetPosition = i + 1 365 BUCKET_SILENT -> gentleState?.targetPosition = i + 1 366 } 367 } 368 369 row ?: continue 370 371 // Check if there are any people notifications 372 peopleNotifsPresent = peopleNotifsPresent || row.entry.bucket == BUCKET_PEOPLE 373 374 if (nextBucket == null) { 375 lastNotifIndex = i 376 } 377 nextBucket = row.entry.bucket 378 } 379 380 if (showHeaders && usingPeopleFiltering && peopleHubVisible) { 381 peopleState?.targetPosition = peopleState?.targetPosition 382 // Insert the people header even if there are no people visible, in order to 383 // show the hub. Put it directly above the next header. 384 ?: alertingState?.targetPosition 385 ?: gentleState?.targetPosition 386 // Put it at the end of the list. 387 ?: lastNotifIndex 388 389 // Offset the target to account for the current position of the people header. 390 peopleState?.targetPosition = peopleState?.currentPosition?.let { current -> 391 peopleState.targetPosition?.let { target -> 392 if (current < target) target - 1 else target 393 } 394 } 395 } 396 397 mediaState?.targetPosition = if (usingMediaControls) 0 else null 398 399 logger.logStr("New header target positions:") 400 logger.logMediaControls(mediaState?.targetPosition ?: -1) 401 logger.logIncomingHeader(incomingState?.targetPosition ?: -1) 402 logger.logConversationsHeader(peopleState?.targetPosition ?: -1) 403 logger.logAlertingHeader(alertingState?.targetPosition ?: -1) 404 logger.logSilentHeader(gentleState?.targetPosition ?: -1) 405 406 // Update headers in reverse order to preserve indices, otherwise movements earlier in the 407 // list will affect the target indices of the headers later in the list. 408 headersOrdered.asIterable().reversed().forEach { it.adjustViewPosition() } 409 410 logger.logStr("Final order:") 411 logShadeContents() 412 logger.logStr("Section boundary update complete") 413 414 // Update headers to reflect state of section contents 415 silentHeaderView?.run { 416 val hasActiveClearableNotifications = this@NotificationSectionsManager.parent 417 .hasActiveClearableNotifications(NotificationStackScrollLayout.ROWS_GENTLE) 418 setAreThereDismissableGentleNotifs(hasActiveClearableNotifications) 419 } 420 peopleHeaderView?.run { 421 canSwipe = showHeaders && peopleHubVisible && !peopleNotifsPresent 422 peopleState?.targetPosition?.let { targetPosition -> 423 if (targetPosition != peopleState.currentPosition) { 424 resetTranslation() 425 } 426 } 427 } 428 } 429 430 private sealed class SectionBounds { 431 432 data class Many( 433 val first: ExpandableView, 434 val last: ExpandableView 435 ) : SectionBounds() 436 437 data class One(val lone: ExpandableView) : SectionBounds() 438 object None : SectionBounds() 439 440 fun addNotif(notif: ExpandableView): SectionBounds = when (this) { 441 is None -> One(notif) 442 is One -> Many(lone, notif) 443 is Many -> copy(last = notif) 444 } 445 446 fun updateSection(section: NotificationSection): Boolean = when (this) { 447 is None -> section.setFirstAndLastVisibleChildren(null, null) 448 is One -> section.setFirstAndLastVisibleChildren(lone, lone) 449 is Many -> section.setFirstAndLastVisibleChildren(first, last) 450 } 451 452 private fun NotificationSection.setFirstAndLastVisibleChildren( 453 first: ExpandableView?, 454 last: ExpandableView? 455 ): Boolean { 456 val firstChanged = setFirstVisibleChild(first) 457 val lastChanged = setLastVisibleChild(last) 458 return firstChanged || lastChanged 459 } 460 } 461 462 /** 463 * Updates the boundaries (as tracked by their first and last views) of the priority sections. 464 * 465 * @return `true` If the last view in the top section changed (so we need to animate). 466 */ 467 fun updateFirstAndLastViewsForAllSections( 468 sections: Array<NotificationSection>, 469 children: List<ExpandableView> 470 ): Boolean { 471 // Create mapping of bucket to section 472 val sectionBounds = children.asSequence() 473 // Group children by bucket 474 .groupingBy { 475 getBucket(it) 476 ?: throw IllegalArgumentException("Cannot find section bucket for view") 477 } 478 // Combine each bucket into a SectionBoundary 479 .foldToSparseArray( 480 SectionBounds.None, 481 size = sections.size, 482 operation = SectionBounds::addNotif 483 ) 484 // Update each section with the associated boundary, tracking if there was a change 485 val changed = sections.fold(false) { changed, section -> 486 val bounds = sectionBounds[section.bucket] ?: SectionBounds.None 487 bounds.updateSection(section) || changed 488 } 489 if (DEBUG) { 490 logSections(sections) 491 } 492 return changed 493 } 494 495 private fun logSections(sections: Array<NotificationSection>) { 496 for (i in sections.indices) { 497 val s = sections[i] 498 val fs = when (val first = s.firstVisibleChild) { 499 null -> "(null)" 500 is ExpandableNotificationRow -> first.entry.key 501 else -> Integer.toHexString(System.identityHashCode(first)) 502 } 503 val ls = when (val last = s.lastVisibleChild) { 504 null -> "(null)" 505 is ExpandableNotificationRow -> last.entry.key 506 else -> Integer.toHexString(System.identityHashCode(last)) 507 } 508 Log.d(TAG, "updateSections: f=$fs s=$i") 509 Log.d(TAG, "updateSections: l=$ls s=$i") 510 } 511 } 512 513 private fun onGentleHeaderClick() { 514 val intent = Intent(Settings.ACTION_NOTIFICATION_SETTINGS) 515 activityStarter.startActivity( 516 intent, 517 true, 518 true, 519 Intent.FLAG_ACTIVITY_SINGLE_TOP) 520 } 521 522 private fun onClearGentleNotifsClick(v: View) { 523 onClearSilentNotifsClickListener?.onClick(v) 524 } 525 526 /** Listener for when the "clear all" button is clicked on the gentle notification header. */ 527 fun setOnClearSilentNotifsClickListener(listener: View.OnClickListener) { 528 onClearSilentNotifsClickListener = listener 529 } 530 531 fun hidePeopleRow() { 532 peopleHubVisible = false 533 updateSectionBoundaries("PeopleHub dismissed") 534 } 535 536 fun setHeaderForegroundColor(@ColorInt color: Int) { 537 peopleHeaderView?.setTextColor(color) 538 silentHeaderView?.setForegroundColor(color) 539 alertingHeaderView?.setForegroundColor(color) 540 } 541 542 companion object { 543 private const val TAG = "NotifSectionsManager" 544 private const val DEBUG = false 545 private const val ENABLE_SNOOZED_CONVERSATION_HUB = false 546 } 547 } 548 549 /** 550 * For now, declare the available notification buckets (sections) here so that other 551 * presentation code can decide what to do based on an entry's buckets 552 */ 553 @Retention(AnnotationRetention.SOURCE) 554 @IntDef( 555 prefix = ["BUCKET_"], 556 value = [ 557 BUCKET_UNKNOWN, BUCKET_MEDIA_CONTROLS, BUCKET_HEADS_UP, BUCKET_FOREGROUND_SERVICE, 558 BUCKET_PEOPLE, BUCKET_ALERTING, BUCKET_SILENT 559 ] 560 ) 561 annotation class PriorityBucket 562 563 const val BUCKET_UNKNOWN = 0 564 const val BUCKET_MEDIA_CONTROLS = 1 565 const val BUCKET_HEADS_UP = 2 566 const val BUCKET_FOREGROUND_SERVICE = 3 567 const val BUCKET_PEOPLE = 4 568 const val BUCKET_ALERTING = 5 569 const val BUCKET_SILENT = 6 570