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