1 /*
2  * Copyright (C) 2020 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.notification
18 
19 import android.app.Notification
20 import android.content.Context
21 import android.content.pm.LauncherApps
22 import android.graphics.drawable.AnimatedImageDrawable
23 import android.os.Handler
24 import android.service.notification.NotificationListenerService.Ranking
25 import android.service.notification.NotificationListenerService.RankingMap
26 import com.android.internal.widget.ConversationLayout
27 import com.android.internal.widget.MessagingImageMessage
28 import com.android.internal.widget.MessagingLayout
29 import com.android.systemui.dagger.SysUISingleton
30 import com.android.systemui.dagger.qualifiers.Main
31 import com.android.systemui.plugins.statusbar.StatusBarStateController
32 import com.android.systemui.statusbar.notification.collection.NotificationEntry
33 import com.android.systemui.statusbar.notification.collection.inflation.BindEventManager
34 import com.android.systemui.statusbar.notification.collection.notifcollection.CommonNotifCollection
35 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
36 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow
37 import com.android.systemui.statusbar.notification.row.NotificationContentInflaterLogger
38 import com.android.systemui.statusbar.notification.row.NotificationContentView
39 import com.android.systemui.statusbar.notification.stack.StackStateAnimator
40 import com.android.systemui.statusbar.policy.HeadsUpManager
41 import com.android.systemui.statusbar.policy.OnHeadsUpChangedListener
42 import com.android.systemui.util.children
43 import java.util.concurrent.ConcurrentHashMap
44 import javax.inject.Inject
45 
46 /** Populates additional information in conversation notifications */
47 class ConversationNotificationProcessor @Inject constructor(
48     private val launcherApps: LauncherApps,
49     private val conversationNotificationManager: ConversationNotificationManager
50 ) {
51     fun processNotification(
52             entry: NotificationEntry,
53             recoveredBuilder: Notification.Builder,
54             logger: NotificationContentInflaterLogger
55     ) {
56         val messagingStyle = recoveredBuilder.style as? Notification.MessagingStyle ?: return
57         messagingStyle.conversationType =
58                 if (entry.ranking.channel.isImportantConversation)
59                     Notification.MessagingStyle.CONVERSATION_TYPE_IMPORTANT
60                 else
61                     Notification.MessagingStyle.CONVERSATION_TYPE_NORMAL
62         entry.ranking.conversationShortcutInfo?.let { shortcutInfo ->
63             logger.logAsyncTaskProgress(entry, "getting shortcut icon")
64             messagingStyle.shortcutIcon = launcherApps.getShortcutIcon(shortcutInfo)
65             shortcutInfo.label?.let { label ->
66                 messagingStyle.conversationTitle = label
67             }
68         }
69         messagingStyle.unreadMessageCount =
70                 conversationNotificationManager.getUnreadCount(entry, recoveredBuilder)
71     }
72 }
73 
74 /**
75  * Tracks state related to animated images inside of notifications. Ex: starting and stopping
76  * animations to conserve CPU and memory.
77  */
78 @SysUISingleton
79 class AnimatedImageNotificationManager @Inject constructor(
80     private val notifCollection: CommonNotifCollection,
81     private val bindEventManager: BindEventManager,
82     private val headsUpManager: HeadsUpManager,
83     private val statusBarStateController: StatusBarStateController
84 ) {
85 
86     private var isStatusBarExpanded = false
87 
88     /** Begins listening to state changes and updating animations accordingly. */
89     fun bind() {
90         headsUpManager.addListener(object : OnHeadsUpChangedListener {
91             override fun onHeadsUpStateChanged(entry: NotificationEntry, isHeadsUp: Boolean) {
92                 updateAnimatedImageDrawables(entry)
93             }
94         })
95         statusBarStateController.addCallback(object : StatusBarStateController.StateListener {
96             override fun onExpandedChanged(isExpanded: Boolean) {
97                 isStatusBarExpanded = isExpanded
98                 notifCollection.allNotifs.forEach(::updateAnimatedImageDrawables)
99             }
100         })
101         bindEventManager.addListener(::updateAnimatedImageDrawables)
102     }
103 
104     private fun updateAnimatedImageDrawables(entry: NotificationEntry) =
105         entry.row?.let { row ->
106             updateAnimatedImageDrawables(row, animating = row.isHeadsUp || isStatusBarExpanded)
107         }
108 
109     private fun updateAnimatedImageDrawables(row: ExpandableNotificationRow, animating: Boolean) =
110             (row.layouts?.asSequence() ?: emptySequence())
111                     .flatMap { layout -> layout.allViews.asSequence() }
112                     .flatMap { view ->
113                         (view as? ConversationLayout)?.messagingGroups?.asSequence()
114                                 ?: (view as? MessagingLayout)?.messagingGroups?.asSequence()
115                                 ?: emptySequence()
116                     }
117                     .flatMap { messagingGroup -> messagingGroup.messageContainer.children }
118                     .mapNotNull { view ->
119                         (view as? MessagingImageMessage)
120                                 ?.let { imageMessage ->
121                                     imageMessage.drawable as? AnimatedImageDrawable
122                                 }
123                     }
124                     .forEach { animatedImageDrawable ->
125                         if (animating) animatedImageDrawable.start()
126                         else animatedImageDrawable.stop()
127                     }
128 }
129 
130 /**
131  * Tracks state related to conversation notifications, and updates the UI of existing notifications
132  * when necessary.
133  * TODO(b/214083332) Refactor this class to use the right coordinators and controllers
134  */
135 @SysUISingleton
136 class ConversationNotificationManager @Inject constructor(
137     bindEventManager: BindEventManager,
138     private val context: Context,
139     private val notifCollection: CommonNotifCollection,
140     @Main private val mainHandler: Handler
141 ) {
142     // Need this state to be thread safe, since it's accessed from the ui thread
143     // (NotificationEntryListener) and a bg thread (NotificationContentInflater)
144     private val states = ConcurrentHashMap<String, ConversationState>()
145 
146     private var notifPanelCollapsed = true
147 
148     private fun updateNotificationRanking(rankingMap: RankingMap) {
149         fun getLayouts(view: NotificationContentView) =
150                 sequenceOf(view.contractedChild, view.expandedChild, view.headsUpChild)
151         val ranking = Ranking()
152         val activeConversationEntries = states.keys.asSequence()
153                 .mapNotNull { notifCollection.getEntry(it) }
154         for (entry in activeConversationEntries) {
155             if (rankingMap.getRanking(entry.sbn.key, ranking) && ranking.isConversation) {
156                 val important = ranking.channel.isImportantConversation
157                 var changed = false
158                 entry.row?.layouts?.asSequence()
159                         ?.flatMap(::getLayouts)
160                         ?.mapNotNull { it as? ConversationLayout }
161                         ?.filterNot { it.isImportantConversation == important }
162                         ?.forEach { layout ->
163                             changed = true
164                             if (important && entry.isMarkedForUserTriggeredMovement) {
165                                 // delay this so that it doesn't animate in until after
166                                 // the notif has been moved in the shade
167                                 mainHandler.postDelayed(
168                                         {
169                                             layout.setIsImportantConversation(
170                                                     important,
171                                                     true)
172                                         },
173                                         IMPORTANCE_ANIMATION_DELAY.toLong())
174                             } else {
175                                 layout.setIsImportantConversation(important, false)
176                             }
177                         }
178             }
179         }
180     }
181 
182     fun onEntryViewBound(entry: NotificationEntry) {
183         if (!entry.ranking.isConversation) {
184             return
185         }
186         fun updateCount(isExpanded: Boolean) {
187             if (isExpanded && (!notifPanelCollapsed || entry.isPinnedAndExpanded)) {
188                 resetCount(entry.key)
189                 entry.row?.let(::resetBadgeUi)
190             }
191         }
192         entry.row?.setOnExpansionChangedListener { isExpanded ->
193             if (entry.row?.isShown == true && isExpanded) {
194                 entry.row.performOnIntrinsicHeightReached {
195                     updateCount(isExpanded)
196                 }
197             } else {
198                 updateCount(isExpanded)
199             }
200         }
201         updateCount(entry.row?.isExpanded == true)
202     }
203 
204     init {
205         notifCollection.addCollectionListener(object : NotifCollectionListener {
206             override fun onRankingUpdate(ranking: RankingMap) =
207                 updateNotificationRanking(ranking)
208 
209             override fun onEntryRemoved(entry: NotificationEntry, reason: Int) =
210                 removeTrackedEntry(entry)
211         })
212         bindEventManager.addListener(::onEntryViewBound)
213     }
214 
215     private fun ConversationState.shouldIncrementUnread(newBuilder: Notification.Builder) =
216             if (notification.flags and Notification.FLAG_ONLY_ALERT_ONCE != 0) {
217                 false
218             } else {
219                 val oldBuilder = Notification.Builder.recoverBuilder(context, notification)
220                 Notification.areStyledNotificationsVisiblyDifferent(oldBuilder, newBuilder)
221             }
222 
223     fun getUnreadCount(entry: NotificationEntry, recoveredBuilder: Notification.Builder): Int =
224             states.compute(entry.key) { _, state ->
225                 val newCount = state?.run {
226                     if (shouldIncrementUnread(recoveredBuilder)) unreadCount + 1 else unreadCount
227                 } ?: 1
228                 ConversationState(newCount, entry.sbn.notification)
229             }!!.unreadCount
230 
231     fun onNotificationPanelExpandStateChanged(isCollapsed: Boolean) {
232         notifPanelCollapsed = isCollapsed
233         if (isCollapsed) return
234 
235         // When the notification panel is expanded, reset the counters of any expanded
236         // conversations
237         val expanded = states
238                 .asSequence()
239                 .mapNotNull { (key, _) ->
240                     notifCollection.getEntry(key)?.let { entry ->
241                         if (entry.row?.isExpanded == true) key to entry
242                         else null
243                     }
244                 }
245                 .toMap()
246         states.replaceAll { key, state ->
247             if (expanded.contains(key)) state.copy(unreadCount = 0)
248             else state
249         }
250         // Update UI separate from the replaceAll call, since ConcurrentHashMap may re-run the
251         // lambda if threads are in contention.
252         expanded.values.asSequence().mapNotNull { it.row }.forEach(::resetBadgeUi)
253     }
254 
255     private fun resetCount(key: String) {
256         states.compute(key) { _, state -> state?.copy(unreadCount = 0) }
257     }
258 
259     private fun removeTrackedEntry(entry: NotificationEntry) {
260         states.remove(entry.key)
261     }
262 
263     private fun resetBadgeUi(row: ExpandableNotificationRow): Unit =
264             (row.layouts?.asSequence() ?: emptySequence())
265                     .flatMap { layout -> layout.allViews.asSequence() }
266                     .mapNotNull { view -> view as? ConversationLayout }
267                     .forEach { convoLayout -> convoLayout.setUnreadCount(0) }
268 
269     private data class ConversationState(val unreadCount: Int, val notification: Notification)
270 
271     companion object {
272         private const val IMPORTANCE_ANIMATION_DELAY =
273                 StackStateAnimator.ANIMATION_DURATION_STANDARD +
274                 StackStateAnimator.ANIMATION_DURATION_PRIORITY_CHANGE +
275                 100
276     }
277 }
278