blob: 56238d0a1b82846c30ae39d6c6878f0bb779b5dd [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.statusbar.notification.stack
import android.annotation.ColorInt
import android.annotation.IntDef
import android.annotation.LayoutRes
import android.content.Intent
import android.provider.Settings
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import com.android.internal.annotations.VisibleForTesting
import com.android.systemui.R
import com.android.systemui.media.KeyguardMediaController
import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.plugins.statusbar.StatusBarStateController
import com.android.systemui.statusbar.StatusBarState
import com.android.systemui.statusbar.notification.NotificationSectionsFeatureManager
import com.android.systemui.statusbar.notification.people.DataListener
import com.android.systemui.statusbar.notification.people.PeopleHubViewAdapter
import com.android.systemui.statusbar.notification.people.PeopleHubViewBoundary
import com.android.systemui.statusbar.notification.people.PersonViewModel
import com.android.systemui.statusbar.notification.people.Subscription
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow
import com.android.systemui.statusbar.notification.row.ExpandableView
import com.android.systemui.statusbar.notification.row.StackScrollerDecorView
import com.android.systemui.statusbar.notification.stack.StackScrollAlgorithm.SectionProvider
import com.android.systemui.statusbar.policy.ConfigurationController
import com.android.systemui.util.children
import com.android.systemui.util.takeUntil
import com.android.systemui.util.foldToSparseArray
import javax.inject.Inject
/**
* Manages the boundaries of the two notification sections (high priority and low priority). Also
* shows/hides the headers for those sections where appropriate.
*
* TODO: Move remaining sections logic from NSSL into this class.
*/
class NotificationSectionsManager @Inject internal constructor(
private val activityStarter: ActivityStarter,
private val statusBarStateController: StatusBarStateController,
private val configurationController: ConfigurationController,
private val peopleHubViewAdapter: PeopleHubViewAdapter,
private val keyguardMediaController: KeyguardMediaController,
private val sectionsFeatureManager: NotificationSectionsFeatureManager,
private val logger: NotificationSectionsLogger
) : SectionProvider {
private val configurationListener = object : ConfigurationController.ConfigurationListener {
override fun onLocaleListChanged() {
reinflateViews(LayoutInflater.from(parent.context))
}
}
private val peopleHubViewBoundary: PeopleHubViewBoundary = object : PeopleHubViewBoundary {
override fun setVisible(isVisible: Boolean) {
if (peopleHubVisible != isVisible) {
peopleHubVisible = isVisible
if (initialized) {
updateSectionBoundaries("PeopleHub visibility changed")
}
}
}
override val associatedViewForClickAnimation: View
get() = peopleHeaderView!!
override val personViewAdapters: Sequence<DataListener<PersonViewModel?>>
get() = peopleHeaderView!!.personViewAdapters
}
private lateinit var parent: NotificationStackScrollLayout
private var initialized = false
private var onClearSilentNotifsClickListener: View.OnClickListener? = null
@get:VisibleForTesting
var silentHeaderView: SectionHeaderView? = null
private set
@get:VisibleForTesting
var alertingHeaderView: SectionHeaderView? = null
private set
@get:VisibleForTesting
var incomingHeaderView: SectionHeaderView? = null
private set
@get:VisibleForTesting
var peopleHeaderView: PeopleHubView? = null
private set
@set:VisibleForTesting
var peopleHubVisible = false
private var peopleHubSubscription: Subscription? = null
@get:VisibleForTesting
var mediaControlsView: MediaHeaderView? = null
private set
/** Must be called before use. */
fun initialize(parent: NotificationStackScrollLayout, layoutInflater: LayoutInflater) {
check(!initialized) { "NotificationSectionsManager already initialized" }
initialized = true
this.parent = parent
reinflateViews(layoutInflater)
configurationController.addCallback(configurationListener)
}
private fun <T : ExpandableView> reinflateView(
view: T?,
layoutInflater: LayoutInflater,
@LayoutRes layoutResId: Int
): T {
var oldPos = -1
view?.let {
view.transientContainer?.removeView(view)
if (view.parent === parent) {
oldPos = parent.indexOfChild(view)
parent.removeView(view)
}
}
val inflated = layoutInflater.inflate(layoutResId, parent, false) as T
if (oldPos != -1) {
parent.addView(inflated, oldPos)
}
return inflated
}
fun createSectionsForBuckets(): Array<NotificationSection> =
sectionsFeatureManager.getNotificationBuckets()
.map { NotificationSection(parent, it) }
.toTypedArray()
/**
* Reinflates the entire notification header, including all decoration views.
*/
fun reinflateViews(layoutInflater: LayoutInflater) {
silentHeaderView = reinflateView(
silentHeaderView, layoutInflater, R.layout.status_bar_notification_section_header
).apply {
setHeaderText(R.string.notification_section_header_gentle)
setOnHeaderClickListener { onGentleHeaderClick() }
setOnClearAllClickListener { onClearGentleNotifsClick(it) }
}
alertingHeaderView = reinflateView(
alertingHeaderView, layoutInflater, R.layout.status_bar_notification_section_header
).apply {
setHeaderText(R.string.notification_section_header_alerting)
setOnHeaderClickListener { onGentleHeaderClick() }
}
peopleHubSubscription?.unsubscribe()
peopleHubSubscription = null
peopleHeaderView = reinflateView(peopleHeaderView, layoutInflater, R.layout.people_strip)
if (ENABLE_SNOOZED_CONVERSATION_HUB) {
peopleHubSubscription = peopleHubViewAdapter.bindView(peopleHubViewBoundary)
}
incomingHeaderView = reinflateView(
incomingHeaderView, layoutInflater, R.layout.status_bar_notification_section_header
).apply {
setHeaderText(R.string.notification_section_header_incoming)
setOnHeaderClickListener { onGentleHeaderClick() }
}
mediaControlsView =
reinflateView(mediaControlsView, layoutInflater, R.layout.keyguard_media_header)
.also(keyguardMediaController::attach)
}
override fun beginsSection(view: View, previous: View?): Boolean =
view === silentHeaderView ||
view === mediaControlsView ||
view === peopleHeaderView ||
view === alertingHeaderView ||
view === incomingHeaderView ||
getBucket(view) != getBucket(previous)
private fun getBucket(view: View?): Int? = when {
view === silentHeaderView -> BUCKET_SILENT
view === incomingHeaderView -> BUCKET_HEADS_UP
view === mediaControlsView -> BUCKET_MEDIA_CONTROLS
view === peopleHeaderView -> BUCKET_PEOPLE
view === alertingHeaderView -> BUCKET_ALERTING
view is ExpandableNotificationRow -> view.entry.bucket
else -> null
}
private fun logShadeChild(i: Int, child: View) {
when {
child === incomingHeaderView -> logger.logIncomingHeader(i)
child === mediaControlsView -> logger.logMediaControls(i)
child === peopleHeaderView -> logger.logConversationsHeader(i)
child === alertingHeaderView -> logger.logAlertingHeader(i)
child === silentHeaderView -> logger.logSilentHeader(i)
child !is ExpandableNotificationRow -> logger.logOther(i, child.javaClass)
else -> {
val isHeadsUp = child.isHeadsUp
when (child.entry.bucket) {
BUCKET_HEADS_UP -> logger.logHeadsUp(i, isHeadsUp)
BUCKET_PEOPLE -> logger.logConversation(i, isHeadsUp)
BUCKET_ALERTING -> logger.logAlerting(i, isHeadsUp)
BUCKET_SILENT -> logger.logSilent(i, isHeadsUp)
}
}
}
}
private fun logShadeContents() = parent.children.forEachIndexed(::logShadeChild)
private val isUsingMultipleSections: Boolean
get() = sectionsFeatureManager.getNumberOfBuckets() > 1
@VisibleForTesting
fun updateSectionBoundaries() = updateSectionBoundaries("test")
private interface SectionUpdateState<out T : ExpandableView> {
val header: T
var currentPosition: Int?
var targetPosition: Int?
fun adjustViewPosition()
}
private fun <T : ExpandableView> expandableViewHeaderState(header: T): SectionUpdateState<T> =
object : SectionUpdateState<T> {
override val header = header
override var currentPosition: Int? = null
override var targetPosition: Int? = null
override fun adjustViewPosition() {
val target = targetPosition
val current = currentPosition
if (target == null) {
if (current != null) {
parent.removeView(header)
}
} else {
if (current == null) {
// If the header is animating away, it will still have a parent, so
// detach it first
// TODO: We should really cancel the active animations here. This will
// happen automatically when the view's intro animation starts, but
// it's a fragile link.
header.transientContainer?.removeTransientView(header)
header.transientContainer = null
parent.addView(header, target)
} else {
parent.changeViewPosition(header, target)
}
}
}
}
private fun <T : StackScrollerDecorView> decorViewHeaderState(
header: T
): SectionUpdateState<T> {
val inner = expandableViewHeaderState(header)
return object : SectionUpdateState<T> by inner {
override fun adjustViewPosition() {
inner.adjustViewPosition()
if (targetPosition != null && currentPosition == null) {
header.isContentVisible = true
}
}
}
}
/**
* Should be called whenever notifs are added, removed, or updated. Updates section boundary
* bookkeeping and adds/moves/removes section headers if appropriate.
*/
fun updateSectionBoundaries(reason: String) {
if (!isUsingMultipleSections) {
return
}
logger.logStartSectionUpdate(reason)
// The overall strategy here is to iterate over the current children of mParent, looking
// for where the sections headers are currently positioned, and where each section begins.
// Then, once we find the start of a new section, we track that position as the "target" for
// the section header, adjusted for the case where existing headers are in front of that
// target, but won't be once they are moved / removed after the pass has completed.
val showHeaders = statusBarStateController.state != StatusBarState.KEYGUARD
val usingPeopleFiltering = sectionsFeatureManager.isFilteringEnabled()
val usingMediaControls = sectionsFeatureManager.isMediaControlsEnabled()
val mediaState = mediaControlsView?.let(::expandableViewHeaderState)
val incomingState = incomingHeaderView?.let(::decorViewHeaderState)
val peopleState = peopleHeaderView?.let(::decorViewHeaderState)
val alertingState = alertingHeaderView?.let(::decorViewHeaderState)
val gentleState = silentHeaderView?.let(::decorViewHeaderState)
fun getSectionState(view: View): SectionUpdateState<ExpandableView>? = when {
view === mediaControlsView -> mediaState
view === incomingHeaderView -> incomingState
view === peopleHeaderView -> peopleState
view === alertingHeaderView -> alertingState
view === silentHeaderView -> gentleState
else -> null
}
val headersOrdered = sequenceOf(
mediaState, incomingState, peopleState, alertingState, gentleState
).filterNotNull()
var peopleNotifsPresent = false
var lastNotifIndex = 0
var nextBucket: Int? = null
var inIncomingSection = false
// Iterating backwards allows for easier construction of the Incoming section, as opposed
// to backtracking when a discontinuity in the sections is discovered.
// Iterating to -1 in order to support the case where a header is at the very top of the
// shade.
for (i in parent.childCount - 1 downTo -1) {
val child: View? = parent.getChildAt(i)
child?.let {
logShadeChild(i, child)
// If this child is a header, update the tracked positions
getSectionState(child)?.let { state ->
state.currentPosition = i
// If headers that should appear above this one in the shade already have a
// target index, then we need to decrement them in order to account for this one
// being either removed, or moved below them.
headersOrdered.takeUntil { it === state }
.forEach { it.targetPosition = it.targetPosition?.minus(1) }
}
}
val row = (child as? ExpandableNotificationRow)
?.takeUnless { it.visibility == View.GONE }
// Is there a section discontinuity? This usually occurs due to HUNs
inIncomingSection = inIncomingSection || nextBucket?.let { next ->
row?.entry?.bucket?.let { curr -> next < curr }
} == true
if (inIncomingSection) {
// Update the bucket to reflect that it's being placed in the Incoming section
row?.entry?.bucket = BUCKET_HEADS_UP
}
// Insert a header in front of the next row, if there's a boundary between it and this
// row, or if it is the topmost row.
val isSectionBoundary = nextBucket != null &&
(child == null || row != null && nextBucket != row.entry.bucket)
if (isSectionBoundary && showHeaders) {
when (nextBucket) {
BUCKET_HEADS_UP -> incomingState?.targetPosition = i + 1
BUCKET_PEOPLE -> peopleState?.targetPosition = i + 1
BUCKET_ALERTING -> alertingState?.targetPosition = i + 1
BUCKET_SILENT -> gentleState?.targetPosition = i + 1
}
}
row ?: continue
// Check if there are any people notifications
peopleNotifsPresent = peopleNotifsPresent || row.entry.bucket == BUCKET_PEOPLE
if (nextBucket == null) {
lastNotifIndex = i
}
nextBucket = row.entry.bucket
}
if (showHeaders && usingPeopleFiltering && peopleHubVisible) {
peopleState?.targetPosition = peopleState?.targetPosition
// Insert the people header even if there are no people visible, in order to
// show the hub. Put it directly above the next header.
?: alertingState?.targetPosition
?: gentleState?.targetPosition
// Put it at the end of the list.
?: lastNotifIndex
// Offset the target to account for the current position of the people header.
peopleState?.targetPosition = peopleState?.currentPosition?.let { current ->
peopleState.targetPosition?.let { target ->
if (current < target) target - 1 else target
}
}
}
mediaState?.targetPosition = if (usingMediaControls) 0 else null
logger.logStr("New header target positions:")
logger.logMediaControls(mediaState?.targetPosition ?: -1)
logger.logIncomingHeader(incomingState?.targetPosition ?: -1)
logger.logConversationsHeader(peopleState?.targetPosition ?: -1)
logger.logAlertingHeader(alertingState?.targetPosition ?: -1)
logger.logSilentHeader(gentleState?.targetPosition ?: -1)
// Update headers in reverse order to preserve indices, otherwise movements earlier in the
// list will affect the target indices of the headers later in the list.
headersOrdered.asIterable().reversed().forEach { it.adjustViewPosition() }
logger.logStr("Final order:")
logShadeContents()
logger.logStr("Section boundary update complete")
// Update headers to reflect state of section contents
silentHeaderView?.run {
val hasActiveClearableNotifications = this@NotificationSectionsManager.parent
.hasActiveClearableNotifications(NotificationStackScrollLayout.ROWS_GENTLE)
setAreThereDismissableGentleNotifs(hasActiveClearableNotifications)
}
peopleHeaderView?.run {
canSwipe = showHeaders && peopleHubVisible && !peopleNotifsPresent
peopleState?.targetPosition?.let { targetPosition ->
if (targetPosition != peopleState.currentPosition) {
resetTranslation()
}
}
}
}
private sealed class SectionBounds {
data class Many(
val first: ExpandableView,
val last: ExpandableView
) : SectionBounds()
data class One(val lone: ExpandableView) : SectionBounds()
object None : SectionBounds()
fun addNotif(notif: ExpandableView): SectionBounds = when (this) {
is None -> One(notif)
is One -> Many(lone, notif)
is Many -> copy(last = notif)
}
fun updateSection(section: NotificationSection): Boolean = when (this) {
is None -> section.setFirstAndLastVisibleChildren(null, null)
is One -> section.setFirstAndLastVisibleChildren(lone, lone)
is Many -> section.setFirstAndLastVisibleChildren(first, last)
}
private fun NotificationSection.setFirstAndLastVisibleChildren(
first: ExpandableView?,
last: ExpandableView?
): Boolean {
val firstChanged = setFirstVisibleChild(first)
val lastChanged = setLastVisibleChild(last)
return firstChanged || lastChanged
}
}
/**
* Updates the boundaries (as tracked by their first and last views) of the priority sections.
*
* @return `true` If the last view in the top section changed (so we need to animate).
*/
fun updateFirstAndLastViewsForAllSections(
sections: Array<NotificationSection>,
children: List<ExpandableView>
): Boolean {
// Create mapping of bucket to section
val sectionBounds = children.asSequence()
// Group children by bucket
.groupingBy {
getBucket(it)
?: throw IllegalArgumentException("Cannot find section bucket for view")
}
// Combine each bucket into a SectionBoundary
.foldToSparseArray(
SectionBounds.None,
size = sections.size,
operation = SectionBounds::addNotif
)
// Update each section with the associated boundary, tracking if there was a change
val changed = sections.fold(false) { changed, section ->
val bounds = sectionBounds[section.bucket] ?: SectionBounds.None
bounds.updateSection(section) || changed
}
if (DEBUG) {
logSections(sections)
}
return changed
}
private fun logSections(sections: Array<NotificationSection>) {
for (i in sections.indices) {
val s = sections[i]
val fs = when (val first = s.firstVisibleChild) {
null -> "(null)"
is ExpandableNotificationRow -> first.entry.key
else -> Integer.toHexString(System.identityHashCode(first))
}
val ls = when (val last = s.lastVisibleChild) {
null -> "(null)"
is ExpandableNotificationRow -> last.entry.key
else -> Integer.toHexString(System.identityHashCode(last))
}
Log.d(TAG, "updateSections: f=$fs s=$i")
Log.d(TAG, "updateSections: l=$ls s=$i")
}
}
private fun onGentleHeaderClick() {
val intent = Intent(Settings.ACTION_NOTIFICATION_SETTINGS)
activityStarter.startActivity(
intent,
true,
true,
Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
private fun onClearGentleNotifsClick(v: View) {
onClearSilentNotifsClickListener?.onClick(v)
}
/** Listener for when the "clear all" button is clicked on the gentle notification header. */
fun setOnClearSilentNotifsClickListener(listener: View.OnClickListener) {
onClearSilentNotifsClickListener = listener
}
fun hidePeopleRow() {
peopleHubVisible = false
updateSectionBoundaries("PeopleHub dismissed")
}
fun setHeaderForegroundColor(@ColorInt color: Int) {
peopleHeaderView?.setTextColor(color)
silentHeaderView?.setForegroundColor(color)
alertingHeaderView?.setForegroundColor(color)
}
companion object {
private const val TAG = "NotifSectionsManager"
private const val DEBUG = false
private const val ENABLE_SNOOZED_CONVERSATION_HUB = false
}
}
/**
* For now, declare the available notification buckets (sections) here so that other
* presentation code can decide what to do based on an entry's buckets
*/
@Retention(AnnotationRetention.SOURCE)
@IntDef(
prefix = ["BUCKET_"],
value = [
BUCKET_UNKNOWN, BUCKET_MEDIA_CONTROLS, BUCKET_HEADS_UP, BUCKET_FOREGROUND_SERVICE,
BUCKET_PEOPLE, BUCKET_ALERTING, BUCKET_SILENT
]
)
annotation class PriorityBucket
const val BUCKET_UNKNOWN = 0
const val BUCKET_MEDIA_CONTROLS = 1
const val BUCKET_HEADS_UP = 2
const val BUCKET_FOREGROUND_SERVICE = 3
const val BUCKET_PEOPLE = 4
const val BUCKET_ALERTING = 5
const val BUCKET_SILENT = 6