• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2017 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 package com.android.systemui.statusbar.notification.row
17 
18 import android.app.Notification
19 import android.app.Person
20 import android.content.Context
21 import android.graphics.drawable.Icon
22 import android.os.AsyncTask
23 import android.os.Build
24 import android.os.CancellationSignal
25 import android.platform.test.annotations.DisableFlags
26 import android.platform.test.annotations.EnableFlags
27 import android.testing.TestableLooper.RunWithLooper
28 import android.util.TypedValue
29 import android.util.TypedValue.COMPLEX_UNIT_SP
30 import android.view.View
31 import android.view.ViewGroup
32 import android.widget.RemoteViews
33 import android.widget.TextView
34 import androidx.test.ext.junit.runners.AndroidJUnit4
35 import androidx.test.filters.SmallTest
36 import com.android.systemui.SysuiTestCase
37 import com.android.systemui.res.R
38 import com.android.systemui.statusbar.NotificationLockscreenUserManager.REDACTION_TYPE_NONE
39 import com.android.systemui.statusbar.NotificationLockscreenUserManager.REDACTION_TYPE_OTP
40 import com.android.systemui.statusbar.NotificationLockscreenUserManager.REDACTION_TYPE_PUBLIC
41 import com.android.systemui.statusbar.NotificationLockscreenUserManager.RedactionType
42 import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips
43 import com.android.systemui.statusbar.notification.ConversationNotificationProcessor
44 import com.android.systemui.statusbar.notification.collection.NotificationEntry
45 import com.android.systemui.statusbar.notification.promoted.FakePromotedNotificationContentExtractor
46 import com.android.systemui.statusbar.notification.promoted.PromotedNotificationUi
47 import com.android.systemui.statusbar.notification.promoted.shared.model.PromotedNotificationContentBuilder
48 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.BindParams
49 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_ALL
50 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_CONTRACTED
51 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_EXPANDED
52 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_HEADS_UP
53 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_PUBLIC
54 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_PUBLIC_SINGLE_LINE
55 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.InflationCallback
56 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.InflationFlag
57 import com.android.systemui.statusbar.notification.row.shared.HeadsUpStatusBarModel
58 import com.android.systemui.statusbar.notification.row.shared.LockscreenOtpRedaction
59 import com.android.systemui.statusbar.notification.row.shared.NewRemoteViews
60 import com.android.systemui.statusbar.notification.row.shared.NotificationContentModel
61 import com.android.systemui.statusbar.notification.row.shared.NotificationRowContentBinderRefactor
62 import com.android.systemui.statusbar.policy.InflatedSmartReplyState
63 import com.android.systemui.statusbar.policy.InflatedSmartReplyViewHolder
64 import com.android.systemui.statusbar.policy.SmartReplyStateInflater
65 import java.util.concurrent.CountDownLatch
66 import java.util.concurrent.Executor
67 import java.util.concurrent.TimeUnit
68 import org.junit.Assert
69 import org.junit.Before
70 import org.junit.Ignore
71 import org.junit.Test
72 import org.junit.runner.RunWith
73 import org.mockito.kotlin.any
74 import org.mockito.kotlin.eq
75 import org.mockito.kotlin.mock
76 import org.mockito.kotlin.spy
77 import org.mockito.kotlin.times
78 import org.mockito.kotlin.verify
79 import org.mockito.kotlin.whenever
80 
81 @SmallTest
82 @RunWith(AndroidJUnit4::class)
83 @RunWithLooper
84 @EnableFlags(NotificationRowContentBinderRefactor.FLAG_NAME, LockscreenOtpRedaction.FLAG_NAME)
85 class NotificationRowContentBinderImplTest : SysuiTestCase() {
86     private lateinit var notificationInflater: NotificationRowContentBinderImpl
87     private lateinit var builder: Notification.Builder
88     private lateinit var row: ExpandableNotificationRow
89     private lateinit var testHelper: NotificationTestHelper
90 
91     private val cache: NotifRemoteViewCache = mock()
92     private val layoutInflaterFactoryProvider =
93         object : NotifLayoutInflaterFactory.Provider {
94             override fun provide(
95                 row: ExpandableNotificationRow,
96                 layoutType: Int,
97             ): NotifLayoutInflaterFactory = mock()
98         }
99     private val smartReplyStateInflater: SmartReplyStateInflater =
100         object : SmartReplyStateInflater {
101             private val inflatedSmartReplyState: InflatedSmartReplyState = mock()
102             private val inflatedSmartReplies: InflatedSmartReplyViewHolder = mock()
103 
104             override fun inflateSmartReplyViewHolder(
105                 sysuiContext: Context,
106                 notifPackageContext: Context,
107                 entry: NotificationEntry,
108                 existingSmartReplyState: InflatedSmartReplyState?,
109                 newSmartReplyState: InflatedSmartReplyState,
110             ): InflatedSmartReplyViewHolder {
111                 return inflatedSmartReplies
112             }
113 
114             override fun inflateSmartReplyState(entry: NotificationEntry): InflatedSmartReplyState {
115                 return inflatedSmartReplyState
116             }
117         }
118     private val promotedNotificationContentExtractor = FakePromotedNotificationContentExtractor()
119     private val conversationNotificationProcessor: ConversationNotificationProcessor = mock()
120 
121     @Before
122     fun setUp() {
123         allowTestableLooperAsMainThread()
124         builder =
125             Notification.Builder(mContext, "no-id")
126                 .setSmallIcon(R.drawable.ic_person)
127                 .setContentTitle("Title")
128                 .setContentText("Text")
129                 .setStyle(Notification.BigTextStyle().bigText("big text"))
130         testHelper = NotificationTestHelper(mContext, mDependency)
131         row = spy(testHelper.createRow(builder.build()))
132         notificationInflater =
133             NotificationRowContentBinderImpl(
134                 cache,
135                 mock(),
136                 conversationNotificationProcessor,
137                 mock(),
138                 smartReplyStateInflater,
139                 layoutInflaterFactoryProvider,
140                 mock<HeadsUpStyleProvider>(),
141                 promotedNotificationContentExtractor,
142                 mock(),
143             )
144     }
145 
146     @Test
147     fun testInflationCallsUpdated() {
148         inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_ALL, row)
149         verify(row).onNotificationUpdated()
150     }
151 
152     @Test
153     fun testInflationOnlyInflatesSetFlags() {
154         inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_HEADS_UP, row)
155         Assert.assertNotNull(row.privateLayout.headsUpChild)
156         verify(row).onNotificationUpdated()
157     }
158 
159     @Test
160     fun testInflationThrowsErrorDoesntCallUpdated() {
161         row.privateLayout.removeAllViews()
162         row.entry.sbn.notification.contentView =
163             RemoteViews(mContext.packageName, R.layout.status_bar)
164         inflateAndWait(
165             true, /* expectingException */
166             notificationInflater,
167             FLAG_CONTENT_VIEW_ALL,
168             REDACTION_TYPE_NONE,
169             row,
170         )
171         Assert.assertTrue(row.privateLayout.childCount == 0)
172         verify(row, times(0)).onNotificationUpdated()
173     }
174 
175     @Test fun testInflationOfSensitiveContentPublicView() {}
176 
177     @Test
178     fun testAsyncTaskRemoved() {
179         row.entry.abortTask()
180         inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_ALL, row)
181         verify(row).onNotificationUpdated()
182     }
183 
184     @Test
185     fun testRemovedNotInflated() {
186         row.setRemoved()
187         notificationInflater.setInflateSynchronously(true)
188         notificationInflater.bindContent(
189             row.entry,
190             row,
191             FLAG_CONTENT_VIEW_ALL,
192             BindParams(false, REDACTION_TYPE_NONE),
193             false, /* forceInflate */
194             null, /* callback */
195         )
196         Assert.assertNull(row.entry.runningTask)
197     }
198 
199     @Test
200     @Ignore("b/345418902")
201     fun testInflationIsRetriedIfAsyncFails() {
202         val headsUpStatusBarModel = HeadsUpStatusBarModel("private", "public")
203         val result =
204             NotificationRowContentBinderImpl.InflationProgress(
205                 packageContext = mContext,
206                 rowImageInflater = RowImageInflater.newInstance(null, reinflating = false),
207                 remoteViews = NewRemoteViews(),
208                 contentModel = NotificationContentModel(headsUpStatusBarModel),
209                 promotedContent = null,
210             )
211         val countDownLatch = CountDownLatch(1)
212         NotificationRowContentBinderImpl.applyRemoteView(
213             AsyncTask.SERIAL_EXECUTOR,
214             inflateSynchronously = false,
215             isMinimized = false,
216             result = result,
217             reInflateFlags = FLAG_CONTENT_VIEW_EXPANDED,
218             inflationId = 0,
219             remoteViewCache = mock(),
220             entry = row.entry,
221             row = row,
222             isNewView = true, /* isNewView */
223             remoteViewClickHandler = { _, _, _ -> true },
224             callback =
225                 object : InflationCallback {
226                     override fun handleInflationException(e: Exception) {
227                         countDownLatch.countDown()
228                         throw RuntimeException("No Exception expected")
229                     }
230 
231                     override fun onAsyncInflationFinished() {
232                         countDownLatch.countDown()
233                     }
234                 },
235             parentLayout = row.privateLayout,
236             existingView = null,
237             existingWrapper = null,
238             runningInflations = HashMap(),
239             applyCallback =
240                 object : NotificationRowContentBinderImpl.ApplyCallback() {
241                     override fun setResultView(v: View) {}
242 
243                     override val remoteView: RemoteViews
244                         get() =
245                             AsyncFailRemoteView(
246                                 mContext.packageName,
247                                 com.android.systemui.tests.R.layout.custom_view_dark,
248                             )
249                 },
250             logger = mock(),
251         )
252         Assert.assertTrue(countDownLatch.await(500, TimeUnit.MILLISECONDS))
253     }
254 
255     @Test
256     fun doesntReapplyDisallowedRemoteView() {
257         builder.setStyle(Notification.MediaStyle())
258         val mediaView = builder.createContentView()
259         builder.setStyle(Notification.DecoratedCustomViewStyle())
260         builder.setCustomContentView(
261             RemoteViews(context.packageName, com.android.systemui.tests.R.layout.custom_view_dark)
262         )
263         val decoratedMediaView = builder.createContentView()
264         Assert.assertFalse(
265             "The decorated media style doesn't allow a view to be reapplied!",
266             NotificationRowContentBinderImpl.canReapplyRemoteView(mediaView, decoratedMediaView),
267         )
268     }
269 
270     @Test
271     @Ignore("b/345418902")
272     fun testUsesSameViewWhenCachedPossibleToReuse() {
273         // GIVEN a cached view.
274         val contractedRemoteView = builder.createContentView()
275         whenever(cache.hasCachedView(row.entry, FLAG_CONTENT_VIEW_CONTRACTED)).thenReturn(true)
276         whenever(cache.getCachedView(row.entry, FLAG_CONTENT_VIEW_CONTRACTED))
277             .thenReturn(contractedRemoteView)
278 
279         // GIVEN existing bound view with same layout id.
280         val view = contractedRemoteView.apply(mContext, null /* parent */)
281         row.privateLayout.setContractedChild(view)
282 
283         // WHEN inflater inflates
284         inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
285 
286         // THEN the view should be re-used
287         Assert.assertEquals(
288             "Binder inflated a new view even though the old one was cached and usable.",
289             view,
290             row.privateLayout.contractedChild,
291         )
292     }
293 
294     @Test
295     fun testInflatesNewViewWhenCachedNotPossibleToReuse() {
296         // GIVEN a cached remote view.
297         val contractedRemoteView = builder.createHeadsUpContentView()
298         whenever(cache.hasCachedView(row.entry, FLAG_CONTENT_VIEW_CONTRACTED)).thenReturn(true)
299         whenever(cache.getCachedView(row.entry, FLAG_CONTENT_VIEW_CONTRACTED))
300             .thenReturn(contractedRemoteView)
301 
302         // GIVEN existing bound view with different layout id.
303         val view: View = TextView(mContext)
304         row.privateLayout.setContractedChild(view)
305 
306         // WHEN inflater inflates
307         inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
308 
309         // THEN the view should be a new view
310         Assert.assertNotEquals(
311             "Binder (somehow) used the same view when inflating.",
312             view,
313             row.privateLayout.contractedChild,
314         )
315     }
316 
317     @Test
318     fun testInflationCachesCreatedRemoteView() {
319         // WHEN inflater inflates
320         inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_CONTRACTED, row)
321 
322         // THEN inflater informs cache of the new remote view
323         verify(cache).putCachedView(eq(row.entry), eq(FLAG_CONTENT_VIEW_CONTRACTED), any())
324     }
325 
326     @Test
327     fun testUnbindRemovesCachedRemoteView() {
328         // WHEN inflated unbinds content
329         notificationInflater.unbindContent(row.entry, row, FLAG_CONTENT_VIEW_HEADS_UP)
330 
331         // THEN inflated informs cache to remove remote view
332         verify(cache).removeCachedView(eq(row.entry), eq(FLAG_CONTENT_VIEW_HEADS_UP))
333     }
334 
335     @Test
336     fun testNotificationViewHeightTooSmallFailsValidation() {
337         val validationError =
338             getValidationError(
339                 measuredHeightDp = 5f,
340                 targetSdk = Build.VERSION_CODES.R,
341                 contentView = mock(),
342             )
343         Assert.assertNotNull(validationError)
344     }
345 
346     @Test
347     fun testNotificationViewHeightPassesForNewerSDK() {
348         val validationError =
349             getValidationError(
350                 measuredHeightDp = 5f,
351                 targetSdk = Build.VERSION_CODES.S,
352                 contentView = mock(),
353             )
354         Assert.assertNull(validationError)
355     }
356 
357     @Test
358     fun testNotificationViewHeightPassesForTemplatedViews() {
359         val validationError =
360             getValidationError(
361                 measuredHeightDp = 5f,
362                 targetSdk = Build.VERSION_CODES.R,
363                 contentView = null,
364             )
365         Assert.assertNull(validationError)
366     }
367 
368     @Test
369     fun testNotificationViewPassesValidation() {
370         val validationError =
371             getValidationError(
372                 measuredHeightDp = 20f,
373                 targetSdk = Build.VERSION_CODES.R,
374                 contentView = mock(),
375             )
376         Assert.assertNull(validationError)
377     }
378 
379     private fun getValidationError(
380         measuredHeightDp: Float,
381         targetSdk: Int,
382         contentView: RemoteViews?,
383     ): String? {
384         val view: View = mock()
385         whenever(view.measuredHeight)
386             .thenReturn(
387                 TypedValue.applyDimension(
388                         COMPLEX_UNIT_SP,
389                         measuredHeightDp,
390                         mContext.resources.displayMetrics,
391                     )
392                     .toInt()
393             )
394         row.entry.targetSdk = targetSdk
395         row.entry.sbn.notification.contentView = contentView
396         return NotificationRowContentBinderImpl.isValidView(view, row.entry, mContext.resources)
397     }
398 
399     @Test
400     fun testInvalidNotificationDoesNotInvokeCallback() {
401         row.privateLayout.removeAllViews()
402         row.entry.sbn.notification.contentView =
403             RemoteViews(
404                 mContext.packageName,
405                 com.android.systemui.tests.R.layout.invalid_notification_height,
406             )
407         inflateAndWait(true, notificationInflater, FLAG_CONTENT_VIEW_ALL, REDACTION_TYPE_NONE, row)
408         Assert.assertEquals(0, row.privateLayout.childCount.toLong())
409         verify(row, times(0)).onNotificationUpdated()
410     }
411 
412     // TODO b/356709333: Add screenshot tests for these views
413     @Test
414     fun testInflatePublicSingleLineView() {
415         row.publicLayout.removeAllViews()
416         inflateAndWait(
417             false,
418             notificationInflater,
419             FLAG_CONTENT_VIEW_PUBLIC_SINGLE_LINE,
420             REDACTION_TYPE_NONE,
421             row,
422         )
423         Assert.assertNotNull(row.publicLayout.mSingleLineView)
424         Assert.assertTrue(row.publicLayout.mSingleLineView is HybridNotificationView)
425     }
426 
427     @Test
428     fun testInflatePublicSingleLineConversationView() {
429         val testPerson = Person.Builder().setName("Person").build()
430         val style = Notification.MessagingStyle(testPerson)
431         val messagingBuilder =
432             Notification.Builder(mContext, "no-id")
433                 .setSmallIcon(R.drawable.ic_person)
434                 .setContentTitle("Title")
435                 .setContentText("Text")
436                 .setStyle(style)
437         whenever(conversationNotificationProcessor.processNotification(any(), any(), any()))
438             .thenReturn(style)
439 
440         val messagingRow = spy(testHelper.createRow(messagingBuilder.build()))
441         messagingRow.publicLayout.removeAllViews()
442         inflateAndWait(
443             false,
444             notificationInflater,
445             FLAG_CONTENT_VIEW_PUBLIC_SINGLE_LINE,
446             REDACTION_TYPE_NONE,
447             messagingRow,
448         )
449         Assert.assertNotNull(messagingRow.publicLayout.mSingleLineView)
450         // assert this is the conversation layout
451         Assert.assertTrue(
452             messagingRow.publicLayout.mSingleLineView is HybridConversationNotificationView
453         )
454     }
455 
456     @Test
457     @DisableFlags(PromotedNotificationUi.FLAG_NAME, StatusBarNotifChips.FLAG_NAME)
458     fun testExtractsPromotedContent_notWhenBothFlagsDisabled() {
459         val content = PromotedNotificationContentBuilder("key").build()
460         promotedNotificationContentExtractor.resetForEntry(row.entry, content)
461 
462         inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_ALL, row)
463 
464         promotedNotificationContentExtractor.verifyZeroExtractCalls()
465     }
466 
467     @Test
468     @EnableFlags(PromotedNotificationUi.FLAG_NAME, StatusBarNotifChips.FLAG_NAME)
469     fun testExtractsPromotedContent_whenBothFlagsEnabled() {
470         val content = PromotedNotificationContentBuilder("key").build()
471         promotedNotificationContentExtractor.resetForEntry(row.entry, content)
472 
473         inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_ALL, row)
474 
475         promotedNotificationContentExtractor.verifyOneExtractCall()
476         Assert.assertEquals(content, row.entry.promotedNotificationContentModels)
477     }
478 
479     @Test
480     @EnableFlags(PromotedNotificationUi.FLAG_NAME, StatusBarNotifChips.FLAG_NAME)
481     fun testExtractsPromotedContent_null() {
482         promotedNotificationContentExtractor.resetForEntry(row.entry, null)
483 
484         inflateAndWait(notificationInflater, FLAG_CONTENT_VIEW_ALL, row)
485 
486         promotedNotificationContentExtractor.verifyOneExtractCall()
487         Assert.assertNull(row.entry.promotedNotificationContentModels)
488     }
489 
490     @Test
491     @Throws(java.lang.Exception::class)
492     @EnableFlags(LockscreenOtpRedaction.FLAG_NAME)
493     fun testSensitiveContentPublicView_messageStyle() {
494         val displayName = "Display Name"
495         val messageText = "Message Text"
496         val contentText = "Content Text"
497         val personIcon = Icon.createWithResource(mContext, R.drawable.ic_person)
498         val testPerson = Person.Builder().setName(displayName).setIcon(personIcon).build()
499         val messagingStyle = Notification.MessagingStyle(testPerson)
500         messagingStyle.addMessage(
501             Notification.MessagingStyle.Message(messageText, System.currentTimeMillis(), testPerson)
502         )
503         messagingStyle.setConversationType(Notification.MessagingStyle.CONVERSATION_TYPE_NORMAL)
504         messagingStyle.setShortcutIcon(personIcon)
505         val messageNotif =
506             Notification.Builder(mContext)
507                 .setSmallIcon(R.drawable.ic_person)
508                 .setStyle(messagingStyle)
509                 .build()
510         val newRow: ExpandableNotificationRow = testHelper.createRow(messageNotif)
511         inflateAndWait(
512             false,
513             notificationInflater,
514             FLAG_CONTENT_VIEW_PUBLIC,
515             REDACTION_TYPE_OTP,
516             newRow,
517         )
518         // The display name should be included, but not the content or message text
519         val publicView = newRow.publicLayout
520         Assert.assertNotNull(publicView)
521         Assert.assertFalse(hasText(publicView, messageText))
522         Assert.assertFalse(hasText(publicView, contentText))
523         Assert.assertTrue(hasText(publicView, displayName))
524     }
525 
526     @Test
527     @Throws(java.lang.Exception::class)
528     @EnableFlags(LockscreenOtpRedaction.FLAG_NAME)
529     fun testSensitiveContentPublicView_nonMessageStyle() {
530         val contentTitle = "Content Title"
531         val contentText = "Content Text"
532         val notif =
533             Notification.Builder(mContext)
534                 .setSmallIcon(R.drawable.ic_person)
535                 .setContentTitle(contentTitle)
536                 .setContentText(contentText)
537                 .build()
538         val newRow: ExpandableNotificationRow = testHelper.createRow(notif)
539         inflateAndWait(
540             false,
541             notificationInflater,
542             FLAG_CONTENT_VIEW_PUBLIC,
543             REDACTION_TYPE_OTP,
544             newRow,
545         )
546         var publicView = newRow.publicLayout
547         Assert.assertNotNull(publicView)
548         Assert.assertFalse(hasText(publicView, contentText))
549         Assert.assertTrue(hasText(publicView, contentTitle))
550 
551         // The standard public view should not use the content title or text
552         inflateAndWait(
553             false,
554             notificationInflater,
555             FLAG_CONTENT_VIEW_PUBLIC,
556             REDACTION_TYPE_PUBLIC,
557             newRow,
558         )
559         publicView = newRow.publicLayout
560         Assert.assertFalse(hasText(publicView, contentText))
561         Assert.assertFalse(hasText(publicView, contentTitle))
562     }
563 
564     @Test
565     @Throws(java.lang.Exception::class)
566     @EnableFlags(android.app.Flags.FLAG_NM_SUMMARIZATION)
567     fun testAllMessagingStyleProcessedAsConversations() {
568         val displayName = "Display Name"
569         val messageText = "Message Text"
570         val personIcon = Icon.createWithResource(mContext, R.drawable.ic_person)
571         val testPerson = Person.Builder().setName(displayName).setIcon(personIcon).build()
572         val messagingStyle = Notification.MessagingStyle(testPerson)
573         messagingStyle.addMessage(
574             Notification.MessagingStyle.Message(messageText, System.currentTimeMillis(), testPerson)
575         )
576         val messageNotif =
577             Notification.Builder(mContext)
578                 .setSmallIcon(R.drawable.ic_person)
579                 .setStyle(messagingStyle)
580                 .build()
581         val newRow: ExpandableNotificationRow = testHelper.createRow(messageNotif)
582 
583         inflateAndWait(
584             false,
585             notificationInflater,
586             FLAG_CONTENT_VIEW_ALL,
587             REDACTION_TYPE_NONE,
588             newRow,
589         )
590         verify(conversationNotificationProcessor).processNotification(any(), any(), any())
591     }
592 
593     private class ExceptionHolder {
594         var exception: Exception? = null
595     }
596 
597     private class AsyncFailRemoteView(packageName: String?, layoutId: Int) :
598         RemoteViews(packageName, layoutId) {
599 
600         override fun apply(context: Context, parent: ViewGroup): View {
601             return super.apply(context, parent)
602         }
603 
604         override fun applyAsync(
605             context: Context,
606             parent: ViewGroup,
607             executor: Executor,
608             listener: OnViewAppliedListener,
609             handler: InteractionHandler?,
610         ): CancellationSignal {
611             executor.execute { listener.onError(RuntimeException("Failed to inflate async")) }
612             return CancellationSignal()
613         }
614 
615         override fun applyAsync(
616             context: Context,
617             parent: ViewGroup,
618             executor: Executor,
619             listener: OnViewAppliedListener,
620         ): CancellationSignal {
621             return applyAsync(context, parent, executor, listener, null)
622         }
623     }
624 
625     companion object {
626         private fun inflateAndWait(
627             inflater: NotificationRowContentBinderImpl,
628             @InflationFlag contentToInflate: Int,
629             row: ExpandableNotificationRow,
630         ) {
631             inflateAndWait(
632                 false /* expectingException */,
633                 inflater,
634                 contentToInflate,
635                 REDACTION_TYPE_NONE,
636                 row,
637             )
638         }
639 
640         private fun inflateAndWait(
641             expectingException: Boolean,
642             inflater: NotificationRowContentBinderImpl,
643             @InflationFlag contentToInflate: Int,
644             @RedactionType redactionType: Int,
645             row: ExpandableNotificationRow,
646         ) {
647             val countDownLatch = CountDownLatch(1)
648             val exceptionHolder = ExceptionHolder()
649             inflater.setInflateSynchronously(true)
650             val callback: InflationCallback =
651                 object : InflationCallback {
652                     override fun handleInflationException(e: Exception) {
653                         if (!expectingException) {
654                             exceptionHolder.exception = e
655                         }
656                         countDownLatch.countDown()
657                     }
658 
659                     override fun onAsyncInflationFinished() {
660                         if (expectingException) {
661                             exceptionHolder.exception =
662                                 RuntimeException(
663                                     "Inflation finished even though there should be an error"
664                                 )
665                         }
666                         countDownLatch.countDown()
667                     }
668                 }
669             inflater.bindContent(
670                 row.entry,
671                 row,
672                 contentToInflate,
673                 BindParams(false, redactionType),
674                 false, /* forceInflate */
675                 callback, /* callback */
676             )
677             Assert.assertTrue(countDownLatch.await(500, TimeUnit.MILLISECONDS))
678             exceptionHolder.exception?.let { throw it }
679         }
680 
681         fun hasText(parent: ViewGroup, text: CharSequence): Boolean {
682             for (i in 0 until parent.childCount) {
683                 val child = parent.getChildAt(i)
684                 if (child is ViewGroup) {
685                     if (hasText(child, text)) {
686                         return true
687                     }
688                 } else if (child is TextView) {
689                     return child.text.toString().contains(text)
690                 }
691             }
692             return false
693         }
694     }
695 }
696