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