1 /* <lambda>null2 * 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.Notification 20 import android.app.Person 21 import android.content.Context 22 import android.graphics.Bitmap 23 import android.graphics.Canvas 24 import android.graphics.Paint 25 import android.graphics.PorterDuff 26 import android.graphics.PorterDuffXfermode 27 import android.graphics.drawable.Drawable 28 import android.graphics.drawable.Icon 29 import android.platform.test.annotations.EnableFlags 30 import android.testing.TestableLooper 31 import androidx.core.graphics.drawable.toBitmap 32 import androidx.test.ext.junit.runners.AndroidJUnit4 33 import androidx.test.filters.SmallTest 34 import com.android.systemui.SysuiTestCase 35 import com.android.systemui.res.R 36 import com.android.systemui.statusbar.notification.row.shared.AsyncHybridViewInflation 37 import com.android.systemui.statusbar.notification.row.ui.viewmodel.ConversationAvatar 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 import kotlin.test.assertEquals 42 import kotlin.test.assertIs 43 import kotlin.test.assertIsNot 44 import kotlin.test.assertNull 45 import kotlin.test.assertTrue 46 import org.junit.Before 47 import org.junit.Test 48 import org.junit.runner.RunWith 49 50 @SmallTest 51 @RunWith(AndroidJUnit4::class) 52 @TestableLooper.RunWithLooper 53 @EnableFlags(AsyncHybridViewInflation.FLAG_NAME) 54 class SingleLineViewInflaterTest : SysuiTestCase() { 55 private lateinit var helper: NotificationTestHelper 56 // Non-group MessagingStyles only have firstSender 57 private lateinit var firstSender: Person 58 private lateinit var lastSender: Person 59 private lateinit var firstSenderIcon: Icon 60 private lateinit var lastSenderIcon: Icon 61 private var firstSenderIconDrawable: Drawable? = null 62 private var lastSenderIconDrawable: Drawable? = null 63 private val currentUser: Person? = null 64 65 private companion object { 66 const val FIRST_SENDER_NAME = "First Sender" 67 const val LAST_SENDER_NAME = "Second Sender" 68 const val LAST_MESSAGE = "How about lunch?" 69 70 const val CONVERSATION_TITLE = "The Sender Family" 71 const val CONTENT_TITLE = "A Cool Group" 72 const val CONTENT_TEXT = "This is an amazing group chat" 73 74 const val SHORTCUT_ID = "Shortcut" 75 } 76 77 @Before 78 fun setUp() { 79 helper = NotificationTestHelper(mContext, mDependency, TestableLooper.get(this)) 80 firstSenderIcon = Icon.createWithBitmap(getBitmap(context, R.drawable.ic_person)) 81 firstSenderIconDrawable = firstSenderIcon.loadDrawable(context) 82 lastSenderIcon = 83 Icon.createWithBitmap( 84 getBitmap(context, com.android.internal.R.drawable.ic_account_circle) 85 ) 86 lastSenderIconDrawable = lastSenderIcon.loadDrawable(context) 87 firstSender = Person.Builder().setName(FIRST_SENDER_NAME).setIcon(firstSenderIcon).build() 88 lastSender = Person.Builder().setName(LAST_SENDER_NAME).setIcon(lastSenderIcon).build() 89 } 90 91 @Test 92 fun createViewModelForNonConversationSingleLineView() { 93 // Given: a non-conversation notification 94 val notificationType = NonMessaging() 95 val notification = getNotification(NonMessaging()) 96 97 // When: inflate the SingleLineViewModel 98 val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) 99 100 // Then: the inflated SingleLineViewModel should be as expected 101 // conversationData: null, because it's not a conversation notification 102 assertEquals(SingleLineViewModel(CONTENT_TITLE, CONTENT_TEXT, null), singleLineViewModel) 103 } 104 105 @Test 106 fun createViewModelForNonGroupConversationNotification() { 107 // Given: a non-group conversation notification 108 val notificationType = OneToOneConversation() 109 val notification = getNotification(notificationType) 110 111 // When: inflate the SingleLineViewModel 112 val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) 113 114 // Then: the inflated SingleLineViewModel should be as expected 115 // titleText: Notification.ConversationTitle 116 // contentText: the last message text 117 // conversationSenderName: null, because it's not a group conversation 118 // conversationData.avatar: a single icon of the last sender 119 assertEquals(CONVERSATION_TITLE, singleLineViewModel.titleText) 120 assertEquals(LAST_MESSAGE, singleLineViewModel.contentText) 121 assertNull( 122 singleLineViewModel.conversationData?.conversationSenderName, 123 "Sender name should be null for one-on-one conversation" 124 ) 125 assertTrue { 126 singleLineViewModel.conversationData 127 ?.avatar 128 ?.equalsTo(SingleIcon(firstSenderIcon.loadDrawable(context))) == true 129 } 130 } 131 132 @Test 133 fun createViewModelForNonGroupLegacyMessagingStyleNotification() { 134 // Given: a non-group legacy messaging style notification 135 val notificationType = LegacyMessaging() 136 val notification = getNotification(notificationType) 137 138 // When: inflate the SingleLineViewModel 139 val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) 140 141 // Then: the inflated SingleLineViewModel should be as expected 142 // titleText: CONVERSATION_TITLE: SENDER_NAME 143 // contentText: the last message text 144 // conversationData: null, because it's not a conversation notification 145 assertEquals("$CONVERSATION_TITLE: $FIRST_SENDER_NAME", singleLineViewModel.titleText) 146 assertEquals(LAST_MESSAGE, singleLineViewModel.contentText) 147 assertNull( 148 singleLineViewModel.conversationData, 149 "conversationData should be null for legacy messaging conversation" 150 ) 151 } 152 153 @Test 154 fun createViewModelForGroupLegacyMessagingStyleNotification() { 155 // Given: a non-group legacy messaging style notification 156 val notificationType = LegacyMessagingGroup() 157 val notification = getNotification(notificationType) 158 159 // When: inflate the SingleLineViewModel 160 val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) 161 162 // Then: the inflated SingleLineViewModel should be as expected 163 // titleText: CONVERSATION_TITLE: LAST_SENDER_NAME 164 // contentText: the last message text 165 // conversationData: null, because it's not a conversation notification 166 assertEquals("$CONVERSATION_TITLE: $LAST_SENDER_NAME", singleLineViewModel.titleText) 167 assertEquals(LAST_MESSAGE, singleLineViewModel.contentText) 168 assertNull( 169 singleLineViewModel.conversationData, 170 "conversationData should be null for legacy messaging conversation" 171 ) 172 } 173 174 @Test 175 fun createViewModelForNonGroupConversationNotificationWithShortcutIcon() { 176 // Given: a non-group conversation notification with a shortcut icon 177 val shortcutIcon = 178 Icon.createWithResource(context, com.android.internal.R.drawable.ic_account_circle) 179 val notificationType = OneToOneConversation(shortcutIcon = shortcutIcon) 180 val notification = getNotification(notificationType) 181 182 // When: inflate the SingleLineViewModel 183 val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) 184 185 // Then: the inflated SingleLineViewModel should be expected 186 // titleText: Notification.ConversationTitle 187 // contentText: the last message text 188 // conversationSenderName: null, because it's not a group conversation 189 // conversationData.avatar: a single icon of the shortcut icon 190 assertEquals(CONVERSATION_TITLE, singleLineViewModel.titleText) 191 assertEquals(LAST_MESSAGE, singleLineViewModel.contentText) 192 assertNull( 193 singleLineViewModel.conversationData?.conversationSenderName, 194 "Sender name should be null for one-on-one conversation" 195 ) 196 assertTrue { 197 singleLineViewModel.conversationData 198 ?.avatar 199 ?.equalsTo(SingleIcon(shortcutIcon.loadDrawable(context))) == true 200 } 201 } 202 203 @Test 204 fun createViewModelForGroupConversationNotificationWithLargeIcon() { 205 // Given: a group conversation notification with a large icon 206 val largeIcon = 207 Icon.createWithResource(context, com.android.internal.R.drawable.ic_account_circle) 208 val notificationType = GroupConversation(largeIcon = largeIcon) 209 val notification = getNotification(notificationType) 210 211 // When: inflate the SingleLineViewModel 212 val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) 213 214 // Then: the inflated SingleLineViewModel should be expected 215 // titleText: Notification.ConversationTitle 216 // contentText: the last message text 217 // conversationSenderName: the last non-user sender's name 218 // conversationData.avatar: a single icon 219 assertEquals(CONVERSATION_TITLE, singleLineViewModel.titleText) 220 assertEquals(LAST_MESSAGE, singleLineViewModel.contentText) 221 assertEquals( 222 context.resources.getString( 223 com.android.internal.R.string.conversation_single_line_name_display, 224 LAST_SENDER_NAME 225 ), 226 singleLineViewModel.conversationData?.conversationSenderName 227 ) 228 assertTrue { 229 singleLineViewModel.conversationData 230 ?.avatar 231 ?.equalsTo(SingleIcon(largeIcon.loadDrawable(context))) == true 232 } 233 } 234 235 @Test 236 fun createViewModelForGroupConversationWithNoIcon() { 237 // Given: a group conversation notification 238 val notificationType = GroupConversation() 239 val notification = getNotification(notificationType) 240 241 // When: inflate the SingleLineViewModel 242 val singleLineViewModel = notification.makeSingleLineViewModel(notificationType) 243 244 // Then: the inflated SingleLineViewModel should be expected 245 // titleText: Notification.ConversationTitle 246 // contentText: the last message text 247 // conversationSenderName: the last non-user sender's name 248 // conversationData.avatar: a face-pile consists the last sender's icon 249 assertEquals(CONVERSATION_TITLE, singleLineViewModel.titleText) 250 assertEquals(LAST_MESSAGE, singleLineViewModel.contentText) 251 assertEquals( 252 context.resources.getString( 253 com.android.internal.R.string.conversation_single_line_name_display, 254 LAST_SENDER_NAME 255 ), 256 singleLineViewModel.conversationData?.conversationSenderName 257 ) 258 259 val backgroundColor = 260 Notification.Builder.recoverBuilder(context, notification) 261 .getBackgroundColor(/* isHeader = */ false) 262 assertTrue { 263 singleLineViewModel.conversationData 264 ?.avatar 265 ?.equalsTo( 266 FacePile( 267 firstSenderIconDrawable, 268 lastSenderIconDrawable, 269 backgroundColor, 270 ) 271 ) == true 272 } 273 } 274 275 sealed class NotificationType(val largeIcon: Icon? = null) 276 277 class NonMessaging(largeIcon: Icon? = null) : NotificationType(largeIcon) 278 279 class LegacyMessaging(largeIcon: Icon? = null) : NotificationType(largeIcon) 280 281 class LegacyMessagingGroup(largeIcon: Icon? = null) : NotificationType(largeIcon) 282 283 class OneToOneConversation(largeIcon: Icon? = null, val shortcutIcon: Icon? = null) : 284 NotificationType(largeIcon) 285 286 class GroupConversation(largeIcon: Icon? = null) : NotificationType(largeIcon) 287 288 private fun getNotification(type: NotificationType): Notification { 289 val notificationBuilder: Notification.Builder = 290 Notification.Builder(mContext, "channelId") 291 .setSmallIcon(R.drawable.ic_person) 292 .setContentTitle(CONTENT_TITLE) 293 .setContentText(CONTENT_TEXT) 294 .setLargeIcon(type.largeIcon) 295 296 val user = Person.Builder().setName("User").build() 297 298 val buildMessagingStyle = 299 Notification.MessagingStyle(user) 300 .setConversationTitle(CONVERSATION_TITLE) 301 .addMessage("Hi", 0, currentUser) 302 303 return when (type) { 304 is NonMessaging -> 305 notificationBuilder 306 .setStyle(Notification.BigTextStyle().bigText("Big Text")) 307 .build() 308 is LegacyMessaging -> { 309 buildMessagingStyle 310 .addMessage("What's up?", 0, firstSender) 311 .addMessage("Not much", 0, currentUser) 312 .addMessage(LAST_MESSAGE, 0, firstSender) 313 314 val notification = notificationBuilder.setStyle(buildMessagingStyle).build() 315 316 assertNull(notification.shortcutId) 317 notification 318 } 319 is LegacyMessagingGroup -> { 320 buildMessagingStyle 321 .addMessage("What's up?", 0, firstSender) 322 .addMessage("Check out my new hover board!", 0, lastSender) 323 .setGroupConversation(true) 324 .addMessage(LAST_MESSAGE, 0, lastSender) 325 326 val notification = notificationBuilder.setStyle(buildMessagingStyle).build() 327 328 assertNull(notification.shortcutId) 329 notification 330 } 331 is OneToOneConversation -> { 332 buildMessagingStyle 333 .addMessage("What's up?", 0, firstSender) 334 .addMessage("Not much", 0, currentUser) 335 .addMessage(LAST_MESSAGE, 0, firstSender) 336 .setShortcutIcon(type.shortcutIcon) 337 notificationBuilder.setShortcutId(SHORTCUT_ID).setStyle(buildMessagingStyle).build() 338 } 339 is GroupConversation -> { 340 buildMessagingStyle 341 .addMessage("What's up?", 0, firstSender) 342 .addMessage("Check out my new hover board!", 0, lastSender) 343 .setGroupConversation(true) 344 .addMessage(LAST_MESSAGE, 0, lastSender) 345 notificationBuilder.setShortcutId(SHORTCUT_ID).setStyle(buildMessagingStyle).build() 346 } 347 } 348 } 349 350 private fun Notification.makeSingleLineViewModel(type: NotificationType): SingleLineViewModel { 351 val builder = Notification.Builder.recoverBuilder(context, this) 352 353 // Validate the recovered builder has the right type of style 354 val expectMessagingStyle = 355 when (type) { 356 is LegacyMessaging, 357 is LegacyMessagingGroup, 358 is OneToOneConversation, 359 is GroupConversation -> true 360 else -> false 361 } 362 if (expectMessagingStyle) { 363 assertIs<Notification.MessagingStyle>( 364 builder.style, 365 "Notification style should be MessagingStyle" 366 ) 367 } else { 368 assertIsNot<Notification.MessagingStyle>( 369 builder.style, 370 message = "Notification style should not be MessagingStyle" 371 ) 372 } 373 374 // Inflate the SingleLineViewModel 375 // Mock the behavior of NotificationRowContentBinder.doInBackground 376 val messagingStyle = builder.getMessagingStyle() 377 val isConversation = type is OneToOneConversation || type is GroupConversation 378 return SingleLineViewInflater.inflateSingleLineViewModel( 379 this, 380 if (isConversation) messagingStyle else null, 381 builder, 382 context 383 ) 384 } 385 386 private fun Notification.Builder.getMessagingStyle(): Notification.MessagingStyle? { 387 return style as? Notification.MessagingStyle 388 } 389 390 private fun getBitmap(context: Context, resId: Int): Bitmap { 391 val largeIconDimension = 392 context.resources.getDimension(R.dimen.conversation_single_line_avatar_size) 393 val d = context.resources.getDrawable(resId) 394 val b = 395 Bitmap.createBitmap( 396 largeIconDimension.toInt(), 397 largeIconDimension.toInt(), 398 Bitmap.Config.ARGB_8888 399 ) 400 val c = Canvas(b) 401 val paint = Paint() 402 c.drawCircle( 403 largeIconDimension / 2, 404 largeIconDimension / 2, 405 largeIconDimension.coerceAtMost(largeIconDimension) / 2, 406 paint 407 ) 408 d.setBounds(0, 0, largeIconDimension.toInt(), largeIconDimension.toInt()) 409 paint.setXfermode(PorterDuffXfermode(PorterDuff.Mode.SRC_IN)) 410 c.saveLayer(0F, 0F, largeIconDimension, largeIconDimension, paint, Canvas.ALL_SAVE_FLAG) 411 d.draw(c) 412 c.restore() 413 return b 414 } 415 416 fun ConversationAvatar.equalsTo(other: ConversationAvatar?): Boolean = 417 when { 418 this === other -> true 419 this is SingleIcon && other is SingleIcon -> equalsTo(other) 420 this is FacePile && other is FacePile -> equalsTo(other) 421 else -> false 422 } 423 424 private fun SingleIcon.equalsTo(other: SingleIcon): Boolean = 425 iconDrawable?.equalsTo(other.iconDrawable) == true 426 427 private fun FacePile.equalsTo(other: FacePile): Boolean = 428 when { 429 bottomBackgroundColor != other.bottomBackgroundColor -> false 430 topIconDrawable?.equalsTo(other.topIconDrawable) != true -> false 431 bottomIconDrawable?.equalsTo(other.bottomIconDrawable) != true -> false 432 else -> true 433 } 434 435 fun Drawable.equalsTo(other: Drawable?): Boolean = 436 when { 437 this === other -> true 438 this.pixelsEqualTo(other) -> true 439 else -> false 440 } 441 442 private fun <T : Drawable> T.pixelsEqualTo(t: T?) = 443 toBitmap().pixelsEqualTo(t?.toBitmap(), false) 444 445 private fun Bitmap.pixelsEqualTo(otherBitmap: Bitmap?, shouldRecycle: Boolean = false) = 446 otherBitmap?.let { other -> 447 if (width == other.width && height == other.height) { 448 val res = toPixels().contentEquals(other.toPixels()) 449 if (shouldRecycle) { 450 doRecycle().also { otherBitmap.doRecycle() } 451 } 452 res 453 } else false 454 } 455 ?: kotlin.run { false } 456 457 private fun Bitmap.toPixels() = 458 IntArray(width * height).apply { getPixels(this, 0, width, 0, 0, width, height) } 459 460 fun Bitmap.doRecycle() { 461 if (!isRecycled) recycle() 462 } 463 } 464