1 /*
2  * 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.LayoutRes
20 import android.util.Log
21 import android.view.LayoutInflater
22 import android.view.View
23 import com.android.internal.annotations.VisibleForTesting
24 import com.android.systemui.R
25 import com.android.systemui.media.KeyguardMediaController
26 import com.android.systemui.plugins.statusbar.StatusBarStateController
27 import com.android.systemui.statusbar.StatusBarState
28 import com.android.systemui.statusbar.notification.NotificationSectionsFeatureManager
29 import com.android.systemui.statusbar.notification.collection.render.SectionHeaderController
30 import com.android.systemui.statusbar.notification.collection.render.ShadeViewManager
31 import com.android.systemui.statusbar.notification.dagger.AlertingHeader
32 import com.android.systemui.statusbar.notification.dagger.IncomingHeader
33 import com.android.systemui.statusbar.notification.dagger.PeopleHeader
34 import com.android.systemui.statusbar.notification.dagger.SilentHeader
35 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow
36 import com.android.systemui.statusbar.notification.row.ExpandableView
37 import com.android.systemui.statusbar.notification.row.StackScrollerDecorView
38 import com.android.systemui.statusbar.notification.stack.StackScrollAlgorithm.SectionProvider
39 import com.android.systemui.statusbar.policy.ConfigurationController
40 import com.android.systemui.util.children
41 import com.android.systemui.util.foldToSparseArray
42 import com.android.systemui.util.takeUntil
43 import javax.inject.Inject
44 
45 /**
46  * Manages the boundaries of the notification sections (incoming, conversations, high priority, and
47  * low priority).
48  *
49  * In the legacy notification pipeline, this is responsible for correctly positioning all section
50  * headers after the [NotificationStackScrollLayout] has had notifications added/removed/changed. In
51  * the new pipeline, this is handled as part of the [ShadeViewManager].
52  *
53  * TODO: Move remaining sections logic from NSSL into this class.
54  */
55 class NotificationSectionsManager @Inject internal constructor(
56     private val statusBarStateController: StatusBarStateController,
57     private val configurationController: ConfigurationController,
58     private val keyguardMediaController: KeyguardMediaController,
59     private val sectionsFeatureManager: NotificationSectionsFeatureManager,
60     private val logger: NotificationSectionsLogger,
61     @IncomingHeader private val incomingHeaderController: SectionHeaderController,
62     @PeopleHeader private val peopleHeaderController: SectionHeaderController,
63     @AlertingHeader private val alertingHeaderController: SectionHeaderController,
64     @SilentHeader private val silentHeaderController: SectionHeaderController
65 ) : SectionProvider {
66 
67     private val configurationListener = object : ConfigurationController.ConfigurationListener {
68         override fun onLocaleListChanged() {
69             reinflateViews(LayoutInflater.from(parent.context))
70         }
71     }
72 
73     private lateinit var parent: NotificationStackScrollLayout
74     private var initialized = false
75 
76     @VisibleForTesting
77     val silentHeaderView: SectionHeaderView?
78         get() = silentHeaderController.headerView
79 
80     @VisibleForTesting
81     val alertingHeaderView: SectionHeaderView?
82         get() = alertingHeaderController.headerView
83 
84     @VisibleForTesting
85     val incomingHeaderView: SectionHeaderView?
86         get() = incomingHeaderController.headerView
87 
88     @VisibleForTesting
89     val peopleHeaderView: SectionHeaderView?
90         get() = peopleHeaderController.headerView
91 
92     @get:VisibleForTesting
93     var mediaControlsView: MediaHeaderView? = null
94         private set
95 
96     /** Must be called before use.  */
97     fun initialize(parent: NotificationStackScrollLayout, layoutInflater: LayoutInflater) {
98         check(!initialized) { "NotificationSectionsManager already initialized" }
99         initialized = true
100         this.parent = parent
101         reinflateViews(layoutInflater)
102         configurationController.addCallback(configurationListener)
103     }
104 
105     private fun <T : ExpandableView> reinflateView(
106         view: T?,
107         layoutInflater: LayoutInflater,
108         @LayoutRes layoutResId: Int
109     ): T {
110         var oldPos = -1
111         view?.let {
112             view.transientContainer?.removeView(view)
113             if (view.parent === parent) {
114                 oldPos = parent.indexOfChild(view)
115                 parent.removeView(view)
116             }
117         }
118         val inflated = layoutInflater.inflate(layoutResId, parent, false) as T
119         if (oldPos != -1) {
120             parent.addView(inflated, oldPos)
121         }
122         return inflated
123     }
124 
125     fun createSectionsForBuckets(): Array<NotificationSection> =
126             sectionsFeatureManager.getNotificationBuckets()
127                     .map { NotificationSection(parent, it) }
128                     .toTypedArray()
129 
130     /**
131      * Reinflates the entire notification header, including all decoration views.
132      */
133     fun reinflateViews(layoutInflater: LayoutInflater) {
134         silentHeaderController.reinflateView(parent)
135         alertingHeaderController.reinflateView(parent)
136         peopleHeaderController.reinflateView(parent)
137         incomingHeaderController.reinflateView(parent)
138         mediaControlsView =
139                 reinflateView(mediaControlsView, layoutInflater, R.layout.keyguard_media_header)
140         keyguardMediaController.attachSinglePaneContainer(mediaControlsView)
141     }
142 
143     override fun beginsSection(view: View, previous: View?): Boolean =
144             view === silentHeaderView ||
145             view === mediaControlsView ||
146             view === peopleHeaderView ||
147             view === alertingHeaderView ||
148             view === incomingHeaderView ||
149             getBucket(view) != getBucket(previous)
150 
151     private fun getBucket(view: View?): Int? = when {
152         view === silentHeaderView -> BUCKET_SILENT
153         view === incomingHeaderView -> BUCKET_HEADS_UP
154         view === mediaControlsView -> BUCKET_MEDIA_CONTROLS
155         view === peopleHeaderView -> BUCKET_PEOPLE
156         view === alertingHeaderView -> BUCKET_ALERTING
157         view is ExpandableNotificationRow -> view.entry.bucket
158         else -> null
159     }
160 
161     private fun logShadeChild(i: Int, child: View) {
162         when {
163             child === incomingHeaderView -> logger.logIncomingHeader(i)
164             child === mediaControlsView -> logger.logMediaControls(i)
165             child === peopleHeaderView -> logger.logConversationsHeader(i)
166             child === alertingHeaderView -> logger.logAlertingHeader(i)
167             child === silentHeaderView -> logger.logSilentHeader(i)
168             child !is ExpandableNotificationRow -> logger.logOther(i, child.javaClass)
169             else -> {
170                 val isHeadsUp = child.isHeadsUp
171                 when (child.entry.bucket) {
172                     BUCKET_HEADS_UP -> logger.logHeadsUp(i, isHeadsUp)
173                     BUCKET_PEOPLE -> logger.logConversation(i, isHeadsUp)
174                     BUCKET_ALERTING -> logger.logAlerting(i, isHeadsUp)
175                     BUCKET_SILENT -> logger.logSilent(i, isHeadsUp)
176                 }
177             }
178         }
179     }
180     private fun logShadeContents() = parent.children.forEachIndexed(::logShadeChild)
181 
182     private val isUsingMultipleSections: Boolean
183         get() = sectionsFeatureManager.getNumberOfBuckets() > 1
184 
185     @VisibleForTesting
186     fun updateSectionBoundaries() = updateSectionBoundaries("test")
187 
188     private interface SectionUpdateState<out T : ExpandableView> {
189         val header: T
190         var currentPosition: Int?
191         var targetPosition: Int?
192         fun adjustViewPosition()
193     }
194 
195     private fun <T : ExpandableView> expandableViewHeaderState(header: T): SectionUpdateState<T> =
196             object : SectionUpdateState<T> {
197                 override val header = header
198                 override var currentPosition: Int? = null
199                 override var targetPosition: Int? = null
200 
201                 override fun adjustViewPosition() {
202                     val target = targetPosition
203                     val current = currentPosition
204                     if (target == null) {
205                         if (current != null) {
206                             parent.removeView(header)
207                         }
208                     } else {
209                         if (current == null) {
210                             // If the header is animating away, it will still have a parent, so
211                             // detach it first
212                             // TODO: We should really cancel the active animations here. This will
213                             //  happen automatically when the view's intro animation starts, but
214                             //  it's a fragile link.
215                             header.transientContainer?.removeTransientView(header)
216                             header.transientContainer = null
217                             parent.addView(header, target)
218                         } else {
219                             parent.changeViewPosition(header, target)
220                         }
221                     }
222                 }
223     }
224 
225     private fun <T : StackScrollerDecorView> decorViewHeaderState(
226         header: T
227     ): SectionUpdateState<T> {
228         val inner = expandableViewHeaderState(header)
229         return object : SectionUpdateState<T> by inner {
230             override fun adjustViewPosition() {
231                 inner.adjustViewPosition()
232                 if (targetPosition != null && currentPosition == null) {
233                     header.isContentVisible = true
234                 }
235             }
236         }
237     }
238 
239     /**
240      * Should be called whenever notifs are added, removed, or updated. Updates section boundary
241      * bookkeeping and adds/moves/removes section headers if appropriate.
242      */
243     fun updateSectionBoundaries(reason: String) {
244         if (!isUsingMultipleSections) {
245             return
246         }
247         logger.logStartSectionUpdate(reason)
248 
249         // The overall strategy here is to iterate over the current children of mParent, looking
250         // for where the sections headers are currently positioned, and where each section begins.
251         // Then, once we find the start of a new section, we track that position as the "target" for
252         // the section header, adjusted for the case where existing headers are in front of that
253         // target, but won't be once they are moved / removed after the pass has completed.
254 
255         val showHeaders = statusBarStateController.state != StatusBarState.KEYGUARD
256         val usingMediaControls = sectionsFeatureManager.isMediaControlsEnabled()
257 
258         val mediaState = mediaControlsView?.let(::expandableViewHeaderState)
259         val incomingState = incomingHeaderView?.let(::decorViewHeaderState)
260         val peopleState = peopleHeaderView?.let(::decorViewHeaderState)
261         val alertingState = alertingHeaderView?.let(::decorViewHeaderState)
262         val gentleState = silentHeaderView?.let(::decorViewHeaderState)
263 
264         fun getSectionState(view: View): SectionUpdateState<ExpandableView>? = when {
265             view === mediaControlsView -> mediaState
266             view === incomingHeaderView -> incomingState
267             view === peopleHeaderView -> peopleState
268             view === alertingHeaderView -> alertingState
269             view === silentHeaderView -> gentleState
270             else -> null
271         }
272 
273         val headersOrdered = sequenceOf(
274                 mediaState, incomingState, peopleState, alertingState, gentleState
275         ).filterNotNull()
276 
277         var peopleNotifsPresent = false
278         var nextBucket: Int? = null
279         var inIncomingSection = false
280 
281         // Iterating backwards allows for easier construction of the Incoming section, as opposed
282         // to backtracking when a discontinuity in the sections is discovered.
283         // Iterating to -1 in order to support the case where a header is at the very top of the
284         // shade.
285         for (i in parent.childCount - 1 downTo -1) {
286             val child: View? = parent.getChildAt(i)
287 
288             child?.let {
289                 logShadeChild(i, child)
290                 // If this child is a header, update the tracked positions
291                 getSectionState(child)?.let { state ->
292                     state.currentPosition = i
293                     // If headers that should appear above this one in the shade already have a
294                     // target index, then we need to decrement them in order to account for this one
295                     // being either removed, or moved below them.
296                     headersOrdered.takeUntil { it === state }
297                             .forEach { it.targetPosition = it.targetPosition?.minus(1) }
298                 }
299             }
300 
301             val row = (child as? ExpandableNotificationRow)
302                     ?.takeUnless { it.visibility == View.GONE }
303 
304             // Is there a section discontinuity? This usually occurs due to HUNs
305             inIncomingSection = inIncomingSection || nextBucket?.let { next ->
306                 row?.entry?.bucket?.let { curr -> next < curr }
307             } == true
308 
309             if (inIncomingSection) {
310                 // Update the bucket to reflect that it's being placed in the Incoming section
311                 row?.entry?.bucket = BUCKET_HEADS_UP
312             }
313 
314             // Insert a header in front of the next row, if there's a boundary between it and this
315             // row, or if it is the topmost row.
316             val isSectionBoundary = nextBucket != null &&
317                     (child == null || row != null && nextBucket != row.entry.bucket)
318             if (isSectionBoundary && showHeaders) {
319                 when (nextBucket) {
320                     BUCKET_SILENT -> gentleState?.targetPosition = i + 1
321                 }
322             }
323 
324             row ?: continue
325 
326             // Check if there are any people notifications
327             peopleNotifsPresent = peopleNotifsPresent || row.entry.bucket == BUCKET_PEOPLE
328             nextBucket = row.entry.bucket
329         }
330 
331         mediaState?.targetPosition = if (usingMediaControls) 0 else null
332 
333         logger.logStr("New header target positions:")
334         logger.logMediaControls(mediaState?.targetPosition ?: -1)
335         logger.logIncomingHeader(incomingState?.targetPosition ?: -1)
336         logger.logConversationsHeader(peopleState?.targetPosition ?: -1)
337         logger.logAlertingHeader(alertingState?.targetPosition ?: -1)
338         logger.logSilentHeader(gentleState?.targetPosition ?: -1)
339 
340         // Update headers in reverse order to preserve indices, otherwise movements earlier in the
341         // list will affect the target indices of the headers later in the list.
342         headersOrdered.asIterable().reversed().forEach { it.adjustViewPosition() }
343 
344         logger.logStr("Final order:")
345         logShadeContents()
346         logger.logStr("Section boundary update complete")
347 
348         // Update headers to reflect state of section contents
349         silentHeaderView?.run {
350             val hasActiveClearableNotifications = this@NotificationSectionsManager.parent
351                     .hasActiveClearableNotifications(NotificationStackScrollLayout.ROWS_GENTLE)
352             setClearSectionButtonEnabled(hasActiveClearableNotifications)
353         }
354     }
355 
356     private sealed class SectionBounds {
357 
358         data class Many(
359             val first: ExpandableView,
360             val last: ExpandableView
361         ) : SectionBounds()
362 
363         data class One(val lone: ExpandableView) : SectionBounds()
364         object None : SectionBounds()
365 
366         fun addNotif(notif: ExpandableView): SectionBounds = when (this) {
367             is None -> One(notif)
368             is One -> Many(lone, notif)
369             is Many -> copy(last = notif)
370         }
371 
372         fun updateSection(section: NotificationSection): Boolean = when (this) {
373             is None -> section.setFirstAndLastVisibleChildren(null, null)
374             is One -> section.setFirstAndLastVisibleChildren(lone, lone)
375             is Many -> section.setFirstAndLastVisibleChildren(first, last)
376         }
377 
378         private fun NotificationSection.setFirstAndLastVisibleChildren(
379             first: ExpandableView?,
380             last: ExpandableView?
381         ): Boolean {
382             val firstChanged = setFirstVisibleChild(first)
383             val lastChanged = setLastVisibleChild(last)
384             return firstChanged || lastChanged
385         }
386     }
387 
388     /**
389      * Updates the boundaries (as tracked by their first and last views) of the priority sections.
390      *
391      * @return `true` If the last view in the top section changed (so we need to animate).
392      */
393     fun updateFirstAndLastViewsForAllSections(
394         sections: Array<NotificationSection>,
395         children: List<ExpandableView>
396     ): Boolean {
397         // Create mapping of bucket to section
398         val sectionBounds = children.asSequence()
399                 // Group children by bucket
400                 .groupingBy {
401                     getBucket(it)
402                             ?: throw IllegalArgumentException("Cannot find section bucket for view")
403                 }
404                 // Combine each bucket into a SectionBoundary
405                 .foldToSparseArray(
406                         SectionBounds.None,
407                         size = sections.size,
408                         operation = SectionBounds::addNotif
409                 )
410         // Update each section with the associated boundary, tracking if there was a change
411         val changed = sections.fold(false) { changed, section ->
412             val bounds = sectionBounds[section.bucket] ?: SectionBounds.None
413             bounds.updateSection(section) || changed
414         }
415         if (DEBUG) {
416             logSections(sections)
417         }
418         return changed
419     }
420 
421     private fun logSections(sections: Array<NotificationSection>) {
422         for (i in sections.indices) {
423             val s = sections[i]
424             val fs = when (val first = s.firstVisibleChild) {
425                 null -> "(null)"
426                 is ExpandableNotificationRow -> first.entry.key
427                 else -> Integer.toHexString(System.identityHashCode(first))
428             }
429             val ls = when (val last = s.lastVisibleChild) {
430                 null -> "(null)"
431                 is ExpandableNotificationRow -> last.entry.key
432                 else -> Integer.toHexString(System.identityHashCode(last))
433             }
434             Log.d(TAG, "updateSections: f=$fs s=$i")
435             Log.d(TAG, "updateSections: l=$ls s=$i")
436         }
437     }
438 
439     fun setHeaderForegroundColor(@ColorInt color: Int) {
440         peopleHeaderView?.setForegroundColor(color)
441         silentHeaderView?.setForegroundColor(color)
442         alertingHeaderView?.setForegroundColor(color)
443     }
444 
445     companion object {
446         private const val TAG = "NotifSectionsManager"
447         private const val DEBUG = false
448     }
449 }
450