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