1 /*
2  * Copyright (C) 2023 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.row
18 
19 import android.app.Flags
20 import android.app.Notification
21 import android.app.Notification.MessagingStyle
22 import android.app.Person
23 import android.content.Context
24 import android.graphics.drawable.Icon
25 import android.util.Log
26 import android.view.LayoutInflater
27 import com.android.app.tracing.traceSection
28 import com.android.internal.R
29 import com.android.internal.widget.MessagingMessage
30 import com.android.internal.widget.PeopleHelper
31 import com.android.systemui.statusbar.notification.collection.NotificationEntry
32 import com.android.systemui.statusbar.notification.logKey
33 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_SINGLE_LINE
34 import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation
35 import com.android.systemui.statusbar.notification.row.ui.viewmodel.ConversationAvatar
36 import com.android.systemui.statusbar.notification.row.ui.viewmodel.ConversationData
37 import com.android.systemui.statusbar.notification.row.ui.viewmodel.FacePile
38 import com.android.systemui.statusbar.notification.row.ui.viewmodel.SingleIcon
39 import com.android.systemui.statusbar.notification.row.ui.viewmodel.SingleLineViewModel
40 
41 /** The inflater of SingleLineViewModel and SingleLineViewHolder */
42 internal object SingleLineViewInflater {
43     const val TAG = "SingleLineViewInflater"
44 
45     /**
46      * Inflate an instance of SingleLineViewModel.
47      *
48      * @param notification the notification to show
49      * @param messagingStyle the MessagingStyle information is only provided for conversation
50      *   notification, not for legacy messaging notifications
51      * @param builder the recovered Notification Builder
52      * @param systemUiContext the context of Android System UI
53      * @return the inflated SingleLineViewModel
54      */
55     @JvmStatic
inflateSingleLineViewModelnull56     fun inflateSingleLineViewModel(
57         notification: Notification,
58         messagingStyle: MessagingStyle?,
59         builder: Notification.Builder,
60         systemUiContext: Context,
61     ): SingleLineViewModel {
62         if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) {
63             return SingleLineViewModel(null, null, null)
64         }
65         peopleHelper.init(systemUiContext)
66         var titleText = HybridGroupManager.resolveTitle(notification)
67         var contentText = HybridGroupManager.resolveText(notification)
68 
69         if (messagingStyle == null) {
70             return SingleLineViewModel(
71                 titleText = titleText,
72                 contentText = contentText,
73                 conversationData = null,
74             )
75         }
76 
77         val isGroupConversation = messagingStyle.isGroupConversation
78 
79         val conversationTextData = messagingStyle.loadConversationTextData(systemUiContext)
80         if (conversationTextData?.conversationTitle?.isNotEmpty() == true) {
81             titleText = conversationTextData.conversationTitle
82         }
83         if (conversationTextData?.conversationText?.isNotEmpty() == true) {
84             contentText = conversationTextData.conversationText
85         }
86 
87         val conversationAvatar =
88             messagingStyle.loadConversationAvatar(
89                 notification = notification,
90                 isGroupConversation = isGroupConversation,
91                 builder = builder,
92                 systemUiContext = systemUiContext
93             )
94 
95         val conversationData =
96             ConversationData(
97                 // We don't show the sender's name for one-to-one conversation
98                 conversationSenderName =
99                     if (isGroupConversation) conversationTextData?.senderName else null,
100                 avatar = conversationAvatar
101             )
102 
103         return SingleLineViewModel(
104             titleText = titleText,
105             contentText = contentText,
106             conversationData = conversationData,
107         )
108     }
109 
110     /** load conversation text data from the MessagingStyle of conversation notifications */
loadConversationTextDatanull111     private fun MessagingStyle.loadConversationTextData(
112         systemUiContext: Context
113     ): ConversationTextData? {
114         if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) {
115             return null
116         }
117         var conversationText: CharSequence?
118 
119         if (messages.isEmpty()) {
120             return null
121         }
122 
123         // load the conversation text
124         val lastMessage = messages[messages.lastIndex]
125         conversationText = lastMessage.text
126         if (conversationText == null && lastMessage.isImageMessage()) {
127             conversationText = findBackUpConversationText(lastMessage, systemUiContext)
128         }
129 
130         // load the sender's name to display
131         val name = lastMessage.senderPerson?.name
132         val senderName =
133             systemUiContext.resources.getString(
134                 R.string.conversation_single_line_name_display,
135                 if (Flags.cleanUpSpansAndNewLines()) name?.toString() else name
136             )
137 
138         // We need to find back-up values for those texts if they are needed and empty
139         return ConversationTextData(
140             conversationTitle = conversationTitle
141                     ?: findBackUpConversationTitle(senderName, systemUiContext),
142             conversationText = conversationText,
143             senderName = senderName,
144         )
145     }
146 
isImageMessagenull147     private fun MessagingStyle.Message.isImageMessage(): Boolean = MessagingMessage.hasImage(this)
148 
149     /** find a back-up conversation title when the conversation title is null. */
150     private fun MessagingStyle.findBackUpConversationTitle(
151         senderName: CharSequence?,
152         systemUiContext: Context,
153     ): CharSequence {
154         if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) {
155             return ""
156         }
157         return if (isGroupConversation) {
158             systemUiContext.resources.getString(R.string.conversation_title_fallback_group_chat)
159         } else {
160             // Is one-to-one, let's try to use the last sender's name
161             // The last back-up is the value of resource: conversation_title_fallback_one_to_one
162             senderName
163                 ?: systemUiContext.resources.getString(
164                     R.string.conversation_title_fallback_one_to_one
165                 )
166         }
167     }
168 
169     /**
170      * find a back-up conversation text when the conversation has null text and is image message.
171      */
findBackUpConversationTextnull172     private fun findBackUpConversationText(
173         message: MessagingStyle.Message,
174         context: Context,
175     ): CharSequence? {
176         if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) {
177             return null
178         }
179         // If the message is not an image message, just return empty, the back-up text for showing
180         // will be SingleLineViewModel.contentText
181         if (!message.isImageMessage()) return null
182         // If is image message, return a placeholder
183         return context.resources.getString(R.string.conversation_single_line_image_placeholder)
184     }
185 
186     /**
187      * The text data that we load from a conversation notification to show in the single-line views.
188      *
189      * Group conversation single-line view should be formatted as:
190      * [conversationTitle, senderName, conversationText]
191      *
192      * One-to-one single-line view should be formatted as:
193      * [conversationTitle (which is equal to the senderName), conversationText]
194      *
195      * @property conversationTitle the title of the conversation, not necessarily the title of the
196      *   notification row. conversationTitle is non-null, though may be empty, in which case we need
197      *   to show the notification title instead.
198      * @property conversationText the text content of the conversation, single-line will use the
199      *   notification's text when conversationText is null
200      * @property senderName the sender's name to be shown in the row when needed. senderName can be
201      *   null
202      */
203     data class ConversationTextData(
204         val conversationTitle: CharSequence,
205         val conversationText: CharSequence?,
206         val senderName: CharSequence?,
207     )
208 
groupMessagesnull209     private fun groupMessages(
210         messages: List<MessagingStyle.Message>,
211         historicMessages: List<MessagingStyle.Message>,
212     ): List<MutableList<MessagingStyle.Message>> {
213         if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) {
214             return listOf()
215         }
216         if (messages.isEmpty() && historicMessages.isEmpty()) return listOf()
217         var currentGroup: MutableList<MessagingStyle.Message>? = null
218         var currentSenderKey: CharSequence? = null
219         val groups = mutableListOf<MutableList<MessagingStyle.Message>>()
220         val histSize = historicMessages.size
221         for (i in 0 until (histSize + messages.size)) {
222             val message = if (i < histSize) historicMessages[i] else messages[i - histSize]
223 
224             val sender = message.senderPerson
225             val senderKey = sender?.getKeyOrName()
226             val isNewGroup = (currentGroup == null) || senderKey != currentSenderKey
227             if (isNewGroup) {
228                 currentGroup = mutableListOf()
229                 groups.add(currentGroup)
230                 currentSenderKey = senderKey
231             }
232             currentGroup?.add(message)
233         }
234         return groups
235     }
236 
loadConversationAvatarnull237     private fun MessagingStyle.loadConversationAvatar(
238         builder: Notification.Builder,
239         notification: Notification,
240         isGroupConversation: Boolean,
241         systemUiContext: Context,
242     ): ConversationAvatar {
243         if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) {
244             return SingleIcon(null)
245         }
246         val userKey = user.getKeyOrName()
247         var conversationIcon: Icon? = null
248         var conversationText: CharSequence? = conversationTitle
249 
250         val groups = groupMessages(messages, historicMessages)
251         val uniqueNames = peopleHelper.mapUniqueNamesToPrefixWithGroupList(groups)
252 
253         if (!isGroupConversation) {
254             // Conversation is one-to-one, load the single icon
255             // Let's resolve the icon / text from the last sender
256             if (shortcutIcon != null) {
257                 conversationIcon = shortcutIcon
258             }
259 
260             for (i in messages.lastIndex downTo 0) {
261                 val message = messages[i]
262                 val sender = message.senderPerson
263                 val senderKey = sender?.getKeyOrName()
264                 if ((sender != null && senderKey != userKey) || i == 0) {
265                     if (conversationText.isNullOrEmpty()) {
266                         // We use the senderName as header text if no conversation title is provided
267                         // (This usually happens for most 1:1 conversations)
268                         conversationText = sender?.name ?: ""
269                     }
270                     if (conversationIcon == null) {
271                         var avatarIcon = sender?.icon
272                         if (avatarIcon == null) {
273                             avatarIcon = builder.getDefaultAvatar(name = conversationText)
274                         }
275                         conversationIcon = avatarIcon
276                     }
277                     break
278                 }
279             }
280         }
281 
282         if (conversationIcon == null) {
283             conversationIcon = notification.getLargeIcon()
284         }
285 
286         // If is one-to-one or the conversation has an icon, return a single icon
287         if (!isGroupConversation || conversationIcon != null) {
288             return SingleIcon(conversationIcon?.loadDrawable(systemUiContext))
289         }
290 
291         // Otherwise, let's find the two last conversations to build a face pile:
292         var secondLastIcon: Icon? = null
293         var lastIcon: Icon? = null
294         var lastKey: CharSequence? = null
295 
296         for (i in groups.lastIndex downTo 0) {
297             val message = groups[i][0]
298             val sender = message.senderPerson ?: user
299             val senderKey = sender.getKeyOrName()
300             val notUser = senderKey != userKey
301             val notIncluded = senderKey != lastKey
302 
303             if ((notUser && notIncluded) || (i == 0 && lastKey == null)) {
304                 if (lastIcon == null) {
305                     lastIcon =
306                         sender.icon
307                             ?: builder.getDefaultAvatar(
308                                 name = sender.name,
309                                 uniqueNames = uniqueNames
310                             )
311                     lastKey = senderKey
312                 } else {
313                     secondLastIcon =
314                         sender.icon
315                             ?: builder.getDefaultAvatar(
316                                 name = sender.name,
317                                 uniqueNames = uniqueNames
318                             )
319                     break
320                 }
321             }
322         }
323 
324         if (lastIcon == null) {
325             lastIcon = builder.getDefaultAvatar(name = "")
326         }
327 
328         if (secondLastIcon == null) {
329             secondLastIcon = builder.getDefaultAvatar(name = "")
330         }
331 
332         return FacePile(
333             topIconDrawable = secondLastIcon.loadDrawable(systemUiContext),
334             bottomIconDrawable = lastIcon.loadDrawable(systemUiContext),
335             bottomBackgroundColor = builder.getBackgroundColor(/* isHeader = */ false),
336         )
337     }
338 
339     @JvmStatic
inflateSingleLineViewHoldernull340     fun inflateSingleLineViewHolder(
341         isConversation: Boolean,
342         reinflateFlags: Int,
343         entry: NotificationEntry,
344         context: Context,
345         logger: NotificationRowContentBinderLogger,
346     ): HybridNotificationView? {
347         if (AsyncHybridViewInflation.isUnexpectedlyInLegacyMode()) return null
348         if (reinflateFlags and FLAG_CONTENT_VIEW_SINGLE_LINE == 0) {
349             return null
350         }
351 
352         logger.logInflateSingleLine(entry, reinflateFlags, isConversation)
353         logger.logAsyncTaskProgress(entry, "inflating single-line content view")
354 
355         var view: HybridNotificationView? = null
356 
357         traceSection("SingleLineViewInflater#inflateSingleLineView") {
358             val inflater = LayoutInflater.from(context)
359             val layoutRes: Int =
360                 if (isConversation)
361                     com.android.systemui.res.R.layout.hybrid_conversation_notification
362                 else com.android.systemui.res.R.layout.hybrid_notification
363             view = inflater.inflate(layoutRes, /* root = */ null) as HybridNotificationView
364             if (view == null) {
365                 Log.wtf(TAG, "Single-line view inflation result is null for entry: ${entry.logKey}")
366             }
367         }
368         return view
369     }
370 
getDefaultAvatarnull371     private fun Notification.Builder.getDefaultAvatar(
372         name: CharSequence?,
373         uniqueNames: PeopleHelper.NameToPrefixMap? = null
374     ): Icon {
375         val layoutColor = getSmallIconColor(/* isHeader = */ false)
376         if (!name.isNullOrEmpty()) {
377             val symbol = uniqueNames?.getPrefix(name) ?: ""
378             return peopleHelper.createAvatarSymbol(
379                 /* name = */ name,
380                 /* symbol = */ symbol,
381                 /* layoutColor = */ layoutColor
382             )
383         }
384         // If name is null, create default avatar with background color
385         // TODO(b/319829062): Investigate caching default icon for color
386         return peopleHelper.createAvatarSymbol(/* name = */ "", /* symbol = */ "", layoutColor)
387     }
388 
Personnull389     private fun Person.getKeyOrName(): CharSequence? = if (key == null) name else key
390 
391     private val peopleHelper = PeopleHelper()
392 }
393