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