1 /*
2  * Copyright (C) 2024 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.media.controls.domain.pipeline
18 
19 import android.app.IUriGrantsManager
20 import android.app.Notification
21 import android.app.Notification.FLAG_NO_CLEAR
22 import android.app.Notification.MediaStyle
23 import android.app.PendingIntent
24 import android.app.UriGrantsManager
25 import android.app.smartspace.SmartspaceAction
26 import android.app.smartspace.SmartspaceConfig
27 import android.app.smartspace.SmartspaceManager
28 import android.app.smartspace.SmartspaceTarget
29 import android.content.Intent
30 import android.content.pm.PackageManager
31 import android.graphics.Bitmap
32 import android.graphics.ImageDecoder
33 import android.graphics.drawable.Icon
34 import android.media.MediaDescription
35 import android.media.MediaMetadata
36 import android.media.session.MediaController
37 import android.media.session.MediaSession
38 import android.media.session.PlaybackState
39 import android.net.Uri
40 import android.os.Bundle
41 import android.os.UserHandle
42 import android.platform.test.annotations.DisableFlags
43 import android.platform.test.annotations.EnableFlags
44 import android.platform.test.flag.junit.FlagsParameterization
45 import android.provider.Settings
46 import android.service.notification.StatusBarNotification
47 import android.testing.TestableLooper.RunWithLooper
48 import androidx.media.utils.MediaConstants
49 import androidx.test.filters.SmallTest
50 import com.android.dx.mockito.inline.extended.ExtendedMockito
51 import com.android.internal.logging.InstanceId
52 import com.android.keyguard.KeyguardUpdateMonitor
53 import com.android.systemui.Flags
54 import com.android.systemui.InstanceIdSequenceFake
55 import com.android.systemui.SysuiTestCase
56 import com.android.systemui.broadcast.BroadcastDispatcher
57 import com.android.systemui.coroutines.collectLastValue
58 import com.android.systemui.dump.DumpManager
59 import com.android.systemui.flags.EnableSceneContainer
60 import com.android.systemui.flags.Flags.MEDIA_REMOTE_RESUME
61 import com.android.systemui.flags.Flags.MEDIA_RESUME_PROGRESS
62 import com.android.systemui.flags.Flags.MEDIA_RETAIN_RECOMMENDATIONS
63 import com.android.systemui.flags.Flags.MEDIA_RETAIN_SESSIONS
64 import com.android.systemui.flags.fakeFeatureFlagsClassic
65 import com.android.systemui.kosmos.testDispatcher
66 import com.android.systemui.kosmos.testScope
67 import com.android.systemui.media.controls.data.repository.mediaDataRepository
68 import com.android.systemui.media.controls.data.repository.mediaFilterRepository
69 import com.android.systemui.media.controls.domain.pipeline.interactor.MediaCarouselInteractor
70 import com.android.systemui.media.controls.domain.resume.MediaResumeListener
71 import com.android.systemui.media.controls.domain.resume.ResumeMediaBrowser
72 import com.android.systemui.media.controls.shared.mediaLogger
73 import com.android.systemui.media.controls.shared.mockMediaLogger
74 import com.android.systemui.media.controls.shared.model.EXTRA_KEY_TRIGGER_SOURCE
75 import com.android.systemui.media.controls.shared.model.EXTRA_VALUE_TRIGGER_PERIODIC
76 import com.android.systemui.media.controls.shared.model.MediaData
77 import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
78 import com.android.systemui.media.controls.shared.model.SmartspaceMediaDataProvider
79 import com.android.systemui.media.controls.util.MediaUiEventLogger
80 import com.android.systemui.media.controls.util.fakeMediaControllerFactory
81 import com.android.systemui.media.controls.util.mediaFlags
82 import com.android.systemui.plugins.activityStarter
83 import com.android.systemui.res.R
84 import com.android.systemui.statusbar.SbnBuilder
85 import com.android.systemui.statusbar.notificationLockscreenUserManager
86 import com.android.systemui.testKosmos
87 import com.android.systemui.util.concurrency.FakeExecutor
88 import com.android.systemui.util.settings.fakeSettings
89 import com.android.systemui.util.time.FakeSystemClock
90 import com.google.common.truth.Truth.assertThat
91 import kotlinx.coroutines.ExperimentalCoroutinesApi
92 import kotlinx.coroutines.test.TestScope
93 import kotlinx.coroutines.test.advanceUntilIdle
94 import kotlinx.coroutines.test.runCurrent
95 import org.junit.After
96 import org.junit.Before
97 import org.junit.Rule
98 import org.junit.Test
99 import org.junit.runner.RunWith
100 import org.mockito.ArgumentCaptor
101 import org.mockito.ArgumentMatchers.anyBoolean
102 import org.mockito.ArgumentMatchers.anyInt
103 import org.mockito.Captor
104 import org.mockito.Mock
105 import org.mockito.Mockito
106 import org.mockito.Mockito.never
107 import org.mockito.Mockito.reset
108 import org.mockito.Mockito.verify
109 import org.mockito.Mockito.verifyNoMoreInteractions
110 import org.mockito.MockitoSession
111 import org.mockito.junit.MockitoJUnit
112 import org.mockito.kotlin.any
113 import org.mockito.kotlin.capture
114 import org.mockito.kotlin.eq
115 import org.mockito.kotlin.mock
116 import org.mockito.kotlin.whenever
117 import org.mockito.quality.Strictness
118 import platform.test.runner.parameterized.ParameterizedAndroidJunit4
119 import platform.test.runner.parameterized.Parameters
120 
121 private const val KEY = "KEY"
122 private const val KEY_2 = "KEY_2"
123 private const val KEY_MEDIA_SMARTSPACE = "MEDIA_SMARTSPACE_ID"
124 private const val SMARTSPACE_CREATION_TIME = 1234L
125 private const val SMARTSPACE_EXPIRY_TIME = 5678L
126 private const val PACKAGE_NAME = "com.example.app"
127 private const val SYSTEM_PACKAGE_NAME = "com.android.systemui"
128 private const val APP_NAME = "SystemUI"
129 private const val SESSION_ARTIST = "artist"
130 private const val SESSION_TITLE = "title"
131 private const val SESSION_BLANK_TITLE = " "
132 private const val SESSION_EMPTY_TITLE = ""
133 private const val USER_ID = 0
<lambda>null134 private val DISMISS_INTENT = Intent().apply { action = "dismiss" }
135 
anyObjectnull136 private fun <T> anyObject(): T {
137     return Mockito.anyObject<T>()
138 }
139 
140 @OptIn(ExperimentalCoroutinesApi::class)
141 @SmallTest
142 @RunWithLooper(setAsMainLooper = true)
143 @RunWith(ParameterizedAndroidJunit4::class)
144 @EnableSceneContainer
145 class MediaDataProcessorTest(flags: FlagsParameterization) : SysuiTestCase() {
<lambda>null146     private val kosmos = testKosmos().apply { mediaLogger = mockMediaLogger }
147     private val testDispatcher = kosmos.testDispatcher
148     private val testScope = kosmos.testScope
149     private val settings = kosmos.fakeSettings
150 
151     @JvmField @Rule val mockito = MockitoJUnit.rule()
152     @Mock lateinit var controller: MediaController
153     @Mock lateinit var transportControls: MediaController.TransportControls
154     @Mock lateinit var playbackInfo: MediaController.PlaybackInfo
155     lateinit var session: MediaSession
156     private lateinit var metadataBuilder: MediaMetadata.Builder
157     lateinit var backgroundExecutor: FakeExecutor
158     private lateinit var foregroundExecutor: FakeExecutor
159     lateinit var uiExecutor: FakeExecutor
160     @Mock lateinit var dumpManager: DumpManager
161     @Mock lateinit var broadcastDispatcher: BroadcastDispatcher
162     @Mock lateinit var mediaTimeoutListener: MediaTimeoutListener
163     @Mock lateinit var mediaResumeListener: MediaResumeListener
164     @Mock lateinit var mediaSessionBasedFilter: MediaSessionBasedFilter
165     @Mock lateinit var mediaDeviceManager: MediaDeviceManager
166     @Mock lateinit var mediaDataCombineLatest: MediaDataCombineLatest
167     @Mock lateinit var listener: MediaDataManager.Listener
168     @Mock lateinit var pendingIntent: PendingIntent
169     @Mock lateinit var smartspaceManager: SmartspaceManager
170     @Mock lateinit var keyguardUpdateMonitor: KeyguardUpdateMonitor
171     private lateinit var smartspaceMediaDataProvider: SmartspaceMediaDataProvider
172     @Mock lateinit var mediaSmartspaceTarget: SmartspaceTarget
173     @Mock private lateinit var mediaRecommendationItem: SmartspaceAction
174     private lateinit var validRecommendationList: List<SmartspaceAction>
175     @Mock private lateinit var mediaSmartspaceBaseAction: SmartspaceAction
176     @Mock private lateinit var logger: MediaUiEventLogger
177     private lateinit var mediaCarouselInteractor: MediaCarouselInteractor
178     private lateinit var mediaDataProcessor: MediaDataProcessor
179     private lateinit var mediaNotification: StatusBarNotification
180     private lateinit var remoteCastNotification: StatusBarNotification
181     @Captor lateinit var mediaDataCaptor: ArgumentCaptor<MediaData>
182     private val clock = FakeSystemClock()
183     @Captor lateinit var stateCallbackCaptor: ArgumentCaptor<(String, PlaybackState) -> Unit>
184     @Captor lateinit var sessionCallbackCaptor: ArgumentCaptor<(String) -> Unit>
185     @Captor lateinit var smartSpaceConfigBuilderCaptor: ArgumentCaptor<SmartspaceConfig>
186     @Mock private lateinit var ugm: IUriGrantsManager
187     @Mock private lateinit var imageSource: ImageDecoder.Source
188 
189     companion object {
190         @JvmStatic
191         @Parameters(name = "{0}")
getParamsnull192         fun getParams(): List<FlagsParameterization> {
193             return FlagsParameterization.progressionOf(
194                 Flags.FLAG_MEDIA_LOAD_METADATA_VIA_MEDIA_DATA_LOADER
195             )
196         }
197     }
198 
199     init {
200         mSetFlagsRule.setFlagsParameterization(flags)
201     }
202 
203     private val fakeFeatureFlags = kosmos.fakeFeatureFlagsClassic
204     private val activityStarter = kosmos.activityStarter
205     private val mediaControllerFactory = kosmos.fakeMediaControllerFactory
206     private val notificationLockscreenUserManager = kosmos.notificationLockscreenUserManager
207     private val mediaFilterRepository = kosmos.mediaFilterRepository
208     private val mediaDataFilter = kosmos.mediaDataFilter
209 
210     private val instanceIdSequence = InstanceIdSequenceFake(1 shl 20)
211 
212     private val originalSmartspaceSetting =
213         Settings.Secure.getInt(
214             context.contentResolver,
215             Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION,
216             1,
217         )
218 
219     private lateinit var staticMockSession: MockitoSession
220 
221     @Before
setupnull222     fun setup() {
223         staticMockSession =
224             ExtendedMockito.mockitoSession()
225                 .mockStatic<UriGrantsManager>(UriGrantsManager::class.java)
226                 .mockStatic<ImageDecoder>(ImageDecoder::class.java)
227                 .strictness(Strictness.LENIENT)
228                 .startMocking()
229         whenever(UriGrantsManager.getService()).thenReturn(ugm)
230         foregroundExecutor = FakeExecutor(clock)
231         backgroundExecutor = FakeExecutor(clock)
232         uiExecutor = FakeExecutor(clock)
233         smartspaceMediaDataProvider = SmartspaceMediaDataProvider()
234         Settings.Secure.putInt(
235             context.contentResolver,
236             Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION,
237             1,
238         )
239         mediaDataProcessor =
240             MediaDataProcessor(
241                 context = context,
242                 applicationScope = testScope,
243                 backgroundDispatcher = testDispatcher,
244                 backgroundExecutor = backgroundExecutor,
245                 uiExecutor = uiExecutor,
246                 foregroundExecutor = foregroundExecutor,
247                 mainDispatcher = testDispatcher,
248                 mediaControllerFactory = mediaControllerFactory,
249                 broadcastDispatcher = broadcastDispatcher,
250                 dumpManager = dumpManager,
251                 activityStarter = activityStarter,
252                 smartspaceMediaDataProvider = smartspaceMediaDataProvider,
253                 useMediaResumption = true,
254                 useQsMediaPlayer = true,
255                 systemClock = clock,
256                 secureSettings = settings,
257                 mediaFlags = kosmos.mediaFlags,
258                 logger = logger,
259                 smartspaceManager = smartspaceManager,
260                 keyguardUpdateMonitor = keyguardUpdateMonitor,
261                 mediaDataRepository = kosmos.mediaDataRepository,
262                 mediaDataLoader = { kosmos.mediaDataLoader },
263                 mediaLogger = kosmos.mediaLogger,
264             )
265         mediaDataProcessor.start()
266         testScope.runCurrent()
267         mediaCarouselInteractor =
268             MediaCarouselInteractor(
269                 applicationScope = testScope.backgroundScope,
270                 mediaDataProcessor = mediaDataProcessor,
271                 mediaTimeoutListener = mediaTimeoutListener,
272                 mediaResumeListener = mediaResumeListener,
273                 mediaSessionBasedFilter = mediaSessionBasedFilter,
274                 mediaDeviceManager = mediaDeviceManager,
275                 mediaDataCombineLatest = mediaDataCombineLatest,
276                 mediaDataFilter = mediaDataFilter,
277                 mediaFilterRepository = mediaFilterRepository,
278                 mediaFlags = kosmos.mediaFlags,
279             )
280         mediaCarouselInteractor.start()
281         verify(mediaTimeoutListener).stateCallback = capture(stateCallbackCaptor)
282         verify(mediaTimeoutListener).sessionCallback = capture(sessionCallbackCaptor)
283         session = MediaSession(context, "MediaDataProcessorTestSession")
284         mediaNotification =
285             SbnBuilder().run {
286                 setUser(UserHandle(USER_ID))
287                 setPkg(PACKAGE_NAME)
288                 modifyNotification(context).also {
289                     it.setSmallIcon(android.R.drawable.ic_media_pause)
290                     it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
291                 }
292                 build()
293             }
294         remoteCastNotification =
295             SbnBuilder().run {
296                 setPkg(SYSTEM_PACKAGE_NAME)
297                 modifyNotification(context).also {
298                     it.setSmallIcon(android.R.drawable.ic_media_pause)
299                     it.setStyle(
300                         MediaStyle().apply {
301                             setMediaSession(session.sessionToken)
302                             setRemotePlaybackInfo("Remote device", 0, null)
303                         }
304                     )
305                 }
306                 build()
307             }
308         metadataBuilder =
309             MediaMetadata.Builder().apply {
310                 putString(MediaMetadata.METADATA_KEY_ARTIST, SESSION_ARTIST)
311                 putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_TITLE)
312             }
313         verify(smartspaceManager).createSmartspaceSession(capture(smartSpaceConfigBuilderCaptor))
314         mediaControllerFactory.setControllerForToken(session.sessionToken, controller)
315         whenever(controller.sessionToken).thenReturn(session.sessionToken)
316         whenever(controller.transportControls).thenReturn(transportControls)
317         whenever(controller.playbackInfo).thenReturn(playbackInfo)
318         whenever(controller.metadata).thenReturn(metadataBuilder.build())
319         whenever(playbackInfo.playbackType)
320             .thenReturn(MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL)
321 
322         // This is an ugly hack for now. The mediaSessionBasedFilter is one of the internal
323         // listeners in the internal processing pipeline. It receives events, but ince it is a
324         // mock, it doesn't pass those events along the chain to the external listeners. So, just
325         // treat mediaSessionBasedFilter as a listener for testing.
326         listener = mediaSessionBasedFilter
327 
328         val recommendationExtras =
329             Bundle().apply {
330                 putString("package_name", PACKAGE_NAME)
331                 putParcelable("dismiss_intent", DISMISS_INTENT)
332             }
333         val icon = Icon.createWithResource(context, android.R.drawable.ic_media_play)
334         whenever(mediaSmartspaceBaseAction.extras).thenReturn(recommendationExtras)
335         whenever(mediaSmartspaceTarget.baseAction).thenReturn(mediaSmartspaceBaseAction)
336         whenever(mediaRecommendationItem.extras).thenReturn(recommendationExtras)
337         whenever(mediaRecommendationItem.icon).thenReturn(icon)
338         validRecommendationList =
339             listOf(mediaRecommendationItem, mediaRecommendationItem, mediaRecommendationItem)
340         whenever(mediaSmartspaceTarget.smartspaceTargetId).thenReturn(KEY_MEDIA_SMARTSPACE)
341         whenever(mediaSmartspaceTarget.featureType).thenReturn(SmartspaceTarget.FEATURE_MEDIA)
342         whenever(mediaSmartspaceTarget.iconGrid).thenReturn(validRecommendationList)
343         whenever(mediaSmartspaceTarget.creationTimeMillis).thenReturn(SMARTSPACE_CREATION_TIME)
344         whenever(mediaSmartspaceTarget.expiryTimeMillis).thenReturn(SMARTSPACE_EXPIRY_TIME)
345         fakeFeatureFlags.set(MEDIA_RETAIN_SESSIONS, false)
346         fakeFeatureFlags.set(MEDIA_RESUME_PROGRESS, false)
347         fakeFeatureFlags.set(MEDIA_REMOTE_RESUME, false)
348         fakeFeatureFlags.set(MEDIA_RETAIN_RECOMMENDATIONS, false)
349         whenever(logger.getNewInstanceId()).thenReturn(instanceIdSequence.newInstanceId())
350         whenever(keyguardUpdateMonitor.isUserInLockdown(any())).thenReturn(false)
351     }
352 
353     @After
tearDownnull354     fun tearDown() {
355         staticMockSession.finishMocking()
356         session.release()
357         mediaDataProcessor.destroy()
358         Settings.Secure.putInt(
359             context.contentResolver,
360             Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION,
361             originalSmartspaceSetting,
362         )
363     }
364 
365     @Test
testsetInactive_active_deactivatesMedianull366     fun testsetInactive_active_deactivatesMedia() {
367         addNotificationAndLoad()
368         val data = mediaDataCaptor.value
369         assertThat(data.active).isTrue()
370 
371         mediaDataProcessor.setInactive(KEY, timedOut = true)
372         assertThat(data.active).isFalse()
373         verify(logger).logMediaTimeout(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
374     }
375 
376     @Test
testsetInactive_resume_dismissesMedianull377     fun testsetInactive_resume_dismissesMedia() {
378         // WHEN resume controls are present, and time out
379         val desc =
380             MediaDescription.Builder().run {
381                 setTitle(SESSION_TITLE)
382                 build()
383             }
384         mediaDataProcessor.addResumptionControls(
385             USER_ID,
386             desc,
387             Runnable {},
388             session.sessionToken,
389             APP_NAME,
390             pendingIntent,
391             PACKAGE_NAME,
392         )
393 
394         testScope.runCurrent()
395         backgroundExecutor.runAllReady()
396         foregroundExecutor.runAllReady()
397         verify(listener)
398             .onMediaDataLoaded(
399                 eq(PACKAGE_NAME),
400                 eq(null),
401                 capture(mediaDataCaptor),
402                 eq(true),
403                 eq(0),
404                 eq(false),
405             )
406 
407         mediaDataProcessor.setInactive(PACKAGE_NAME, timedOut = true)
408         verify(logger)
409             .logMediaTimeout(anyInt(), eq(PACKAGE_NAME), eq(mediaDataCaptor.value.instanceId))
410 
411         // THEN it is removed and listeners are informed
412         foregroundExecutor.advanceClockToLast()
413         foregroundExecutor.runAllReady()
414         verify(listener).onMediaDataRemoved(PACKAGE_NAME, false)
415     }
416 
417     @Test
testLoadsMetadataOnBackgroundnull418     fun testLoadsMetadataOnBackground() {
419         mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
420         testScope.assertRunAllReady(foreground = 0, background = 1)
421     }
422 
423     @Test
testLoadMetadata_withExplicitIndicatornull424     fun testLoadMetadata_withExplicitIndicator() {
425         whenever(controller.metadata)
426             .thenReturn(
427                 metadataBuilder
428                     .putLong(
429                         MediaConstants.METADATA_KEY_IS_EXPLICIT,
430                         MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT,
431                     )
432                     .build()
433             )
434 
435         mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
436 
437         testScope.assertRunAllReady(foreground = 1, background = 1)
438         verify(listener)
439             .onMediaDataLoaded(
440                 eq(KEY),
441                 eq(null),
442                 capture(mediaDataCaptor),
443                 eq(true),
444                 eq(0),
445                 eq(false),
446             )
447         assertThat(mediaDataCaptor.value!!.isExplicit).isTrue()
448     }
449 
450     @Test
testOnMetaDataLoaded_withoutExplicitIndicatornull451     fun testOnMetaDataLoaded_withoutExplicitIndicator() {
452         mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
453 
454         testScope.assertRunAllReady(foreground = 1, background = 1)
455         verify(listener)
456             .onMediaDataLoaded(
457                 eq(KEY),
458                 eq(null),
459                 capture(mediaDataCaptor),
460                 eq(true),
461                 eq(0),
462                 eq(false),
463             )
464         assertThat(mediaDataCaptor.value!!.isExplicit).isFalse()
465     }
466 
467     @Test
testOnMetaDataLoaded_callsListenernull468     fun testOnMetaDataLoaded_callsListener() {
469         addNotificationAndLoad()
470         verify(logger)
471             .logActiveMediaAdded(
472                 anyInt(),
473                 eq(PACKAGE_NAME),
474                 eq(mediaDataCaptor.value.instanceId),
475                 eq(MediaData.PLAYBACK_LOCAL),
476             )
477     }
478 
479     @Test
testOnMetaDataLoaded_conservesActiveFlagnull480     fun testOnMetaDataLoaded_conservesActiveFlag() {
481         mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
482         testScope.assertRunAllReady(foreground = 1, background = 1)
483         verify(listener)
484             .onMediaDataLoaded(
485                 eq(KEY),
486                 eq(null),
487                 capture(mediaDataCaptor),
488                 eq(true),
489                 eq(0),
490                 eq(false),
491             )
492         assertThat(mediaDataCaptor.value!!.active).isTrue()
493     }
494 
495     @Test
testOnNotificationAdded_isRcn_markedRemotenull496     fun testOnNotificationAdded_isRcn_markedRemote() {
497         addNotificationAndLoad(remoteCastNotification)
498 
499         assertThat(mediaDataCaptor.value!!.playbackLocation)
500             .isEqualTo(MediaData.PLAYBACK_CAST_REMOTE)
501         verify(logger)
502             .logActiveMediaAdded(
503                 anyInt(),
504                 eq(SYSTEM_PACKAGE_NAME),
505                 eq(mediaDataCaptor.value.instanceId),
506                 eq(MediaData.PLAYBACK_CAST_REMOTE),
507             )
508     }
509 
510     @Test
testOnNotificationAdded_hasSubstituteName_isUsednull511     fun testOnNotificationAdded_hasSubstituteName_isUsed() {
512         val subName = "Substitute Name"
513         val notif =
514             SbnBuilder().run {
515                 modifyNotification(context).also {
516                     it.extras =
517                         Bundle().apply {
518                             putString(Notification.EXTRA_SUBSTITUTE_APP_NAME, subName)
519                         }
520                     it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
521                 }
522                 build()
523             }
524 
525         mediaDataProcessor.onNotificationAdded(KEY, notif)
526         testScope.assertRunAllReady(foreground = 1, background = 1)
527         verify(listener)
528             .onMediaDataLoaded(
529                 eq(KEY),
530                 eq(null),
531                 capture(mediaDataCaptor),
532                 eq(true),
533                 eq(0),
534                 eq(false),
535             )
536 
537         assertThat(mediaDataCaptor.value!!.app).isEqualTo(subName)
538     }
539 
540     @Test
testLoadMediaDataInBg_invalidTokenNoCrashnull541     fun testLoadMediaDataInBg_invalidTokenNoCrash() {
542         val bundle = Bundle()
543         // wrong data type
544         bundle.putParcelable(Notification.EXTRA_MEDIA_SESSION, Bundle())
545         val rcn =
546             SbnBuilder().run {
547                 setPkg(SYSTEM_PACKAGE_NAME)
548                 modifyNotification(context).also {
549                     it.setSmallIcon(android.R.drawable.ic_media_pause)
550                     it.addExtras(bundle)
551                     it.setStyle(
552                         MediaStyle().apply { setRemotePlaybackInfo("Remote device", 0, null) }
553                     )
554                 }
555                 build()
556             }
557 
558         mediaDataProcessor.loadMediaDataInBg(KEY, rcn, null)
559         // no crash even though the data structure is incorrect
560     }
561 
562     @Test
testLoadMediaDataInBg_invalidMediaRemoteIntentNoCrashnull563     fun testLoadMediaDataInBg_invalidMediaRemoteIntentNoCrash() {
564         val bundle = Bundle()
565         // wrong data type
566         bundle.putParcelable(Notification.EXTRA_MEDIA_REMOTE_INTENT, Bundle())
567         val rcn =
568             SbnBuilder().run {
569                 setPkg(SYSTEM_PACKAGE_NAME)
570                 modifyNotification(context).also {
571                     it.setSmallIcon(android.R.drawable.ic_media_pause)
572                     it.addExtras(bundle)
573                     it.setStyle(
574                         MediaStyle().apply {
575                             setMediaSession(session.sessionToken)
576                             setRemotePlaybackInfo("Remote device", 0, null)
577                         }
578                     )
579                 }
580                 build()
581             }
582 
583         mediaDataProcessor.loadMediaDataInBg(KEY, rcn, null)
584         // no crash even though the data structure is incorrect
585     }
586 
587     @Test
testOnNotificationRemoved_callsListenernull588     fun testOnNotificationRemoved_callsListener() {
589         addNotificationAndLoad()
590         val data = mediaDataCaptor.value
591         mediaDataProcessor.onNotificationRemoved(KEY)
592         verify(listener).onMediaDataRemoved(eq(KEY), eq(false))
593         verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
594     }
595 
596     @Test
testOnNotificationAdded_emptyTitle_hasPlaceholdernull597     fun testOnNotificationAdded_emptyTitle_hasPlaceholder() {
598         // When the manager has a notification with an empty title, and the app is not
599         // required to include a non-empty title
600         val mockPackageManager = mock<PackageManager>()
601         context.setMockPackageManager(mockPackageManager)
602         whenever(mockPackageManager.getApplicationLabel(any())).thenReturn(APP_NAME)
603         whenever(controller.metadata)
604             .thenReturn(
605                 metadataBuilder
606                     .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_EMPTY_TITLE)
607                     .build()
608             )
609         mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
610 
611         // Then a media control is created with a placeholder title string
612         testScope.assertRunAllReady(foreground = 1, background = 1)
613         verify(listener)
614             .onMediaDataLoaded(
615                 eq(KEY),
616                 eq(null),
617                 capture(mediaDataCaptor),
618                 eq(true),
619                 eq(0),
620                 eq(false),
621             )
622         val placeholderTitle = context.getString(R.string.controls_media_empty_title, APP_NAME)
623         assertThat(mediaDataCaptor.value.song).isEqualTo(placeholderTitle)
624     }
625 
626     @Test
testOnNotificationAdded_blankTitle_hasPlaceholdernull627     fun testOnNotificationAdded_blankTitle_hasPlaceholder() {
628         // GIVEN that the manager has a notification with a blank title, and the app is not
629         // required to include a non-empty title
630         val mockPackageManager = mock<PackageManager>()
631         context.setMockPackageManager(mockPackageManager)
632         whenever(mockPackageManager.getApplicationLabel(any())).thenReturn(APP_NAME)
633         whenever(controller.metadata)
634             .thenReturn(
635                 metadataBuilder
636                     .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_BLANK_TITLE)
637                     .build()
638             )
639         mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
640 
641         // Then a media control is created with a placeholder title string
642         testScope.assertRunAllReady(foreground = 1, background = 1)
643         verify(listener)
644             .onMediaDataLoaded(
645                 eq(KEY),
646                 eq(null),
647                 capture(mediaDataCaptor),
648                 eq(true),
649                 eq(0),
650                 eq(false),
651             )
652         val placeholderTitle = context.getString(R.string.controls_media_empty_title, APP_NAME)
653         assertThat(mediaDataCaptor.value.song).isEqualTo(placeholderTitle)
654     }
655 
656     @Test
testOnNotificationAdded_emptyMetadata_usesNotificationTitlenull657     fun testOnNotificationAdded_emptyMetadata_usesNotificationTitle() {
658         // When the app sets the metadata title fields to empty strings, but does include a
659         // non-blank notification title
660         val mockPackageManager = mock<PackageManager>()
661         context.setMockPackageManager(mockPackageManager)
662         whenever(mockPackageManager.getApplicationLabel(any())).thenReturn(APP_NAME)
663         whenever(controller.metadata)
664             .thenReturn(
665                 metadataBuilder
666                     .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_EMPTY_TITLE)
667                     .putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, SESSION_EMPTY_TITLE)
668                     .build()
669             )
670         mediaNotification =
671             SbnBuilder().run {
672                 setPkg(PACKAGE_NAME)
673                 modifyNotification(context).also {
674                     it.setSmallIcon(android.R.drawable.ic_media_pause)
675                     it.setContentTitle(SESSION_TITLE)
676                     it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
677                 }
678                 build()
679             }
680         mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
681 
682         // Then the media control is added using the notification's title
683         testScope.assertRunAllReady(foreground = 1, background = 1)
684         verify(listener)
685             .onMediaDataLoaded(
686                 eq(KEY),
687                 eq(null),
688                 capture(mediaDataCaptor),
689                 eq(true),
690                 eq(0),
691                 eq(false),
692             )
693         assertThat(mediaDataCaptor.value.song).isEqualTo(SESSION_TITLE)
694     }
695 
696     @Test
testOnNotificationRemoved_emptyTitle_notConvertednull697     fun testOnNotificationRemoved_emptyTitle_notConverted() {
698         // GIVEN that the manager has a notification with a resume action and empty title.
699         addNotificationAndLoad()
700         val data = mediaDataCaptor.value
701         val instanceId = data.instanceId
702         assertThat(data.resumption).isFalse()
703         mediaDataProcessor.onMediaDataLoaded(
704             KEY,
705             null,
706             data.copy(song = SESSION_EMPTY_TITLE, resumeAction = Runnable {}),
707         )
708 
709         // WHEN the notification is removed
710         reset(listener)
711         mediaDataProcessor.onNotificationRemoved(KEY)
712 
713         // THEN active media is not converted to resume.
714         verify(listener, never())
715             .onMediaDataLoaded(
716                 eq(PACKAGE_NAME),
717                 eq(KEY),
718                 capture(mediaDataCaptor),
719                 eq(true),
720                 eq(0),
721                 eq(false),
722             )
723         verify(logger, never())
724             .logActiveConvertedToResume(anyInt(), eq(PACKAGE_NAME), eq(instanceId))
725         verify(logger, never()).logResumeMediaAdded(anyInt(), eq(PACKAGE_NAME), any())
726         verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(instanceId))
727     }
728 
729     @Test
testOnNotificationRemoved_blankTitle_notConvertednull730     fun testOnNotificationRemoved_blankTitle_notConverted() {
731         // GIVEN that the manager has a notification with a resume action and blank title.
732         addNotificationAndLoad()
733         val data = mediaDataCaptor.value
734         val instanceId = data.instanceId
735         assertThat(data.resumption).isFalse()
736         mediaDataProcessor.onMediaDataLoaded(
737             KEY,
738             null,
739             data.copy(song = SESSION_BLANK_TITLE, resumeAction = Runnable {}),
740         )
741 
742         // WHEN the notification is removed
743         reset(listener)
744         mediaDataProcessor.onNotificationRemoved(KEY)
745 
746         // THEN active media is not converted to resume.
747         verify(listener, never())
748             .onMediaDataLoaded(
749                 eq(PACKAGE_NAME),
750                 eq(KEY),
751                 capture(mediaDataCaptor),
752                 eq(true),
753                 eq(0),
754                 eq(false),
755             )
756         verify(logger, never())
757             .logActiveConvertedToResume(anyInt(), eq(PACKAGE_NAME), eq(instanceId))
758         verify(logger, never()).logResumeMediaAdded(anyInt(), eq(PACKAGE_NAME), any())
759         verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(instanceId))
760     }
761 
762     @Test
testOnNotificationRemoved_withResumptionnull763     fun testOnNotificationRemoved_withResumption() {
764         // GIVEN that the manager has a notification with a resume action
765         addNotificationAndLoad()
766         val data = mediaDataCaptor.value
767         assertThat(data.resumption).isFalse()
768         mediaDataProcessor.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
769         // WHEN the notification is removed
770         mediaDataProcessor.onNotificationRemoved(KEY)
771         // THEN the media data indicates that it is for resumption
772         verify(listener)
773             .onMediaDataLoaded(
774                 eq(PACKAGE_NAME),
775                 eq(KEY),
776                 capture(mediaDataCaptor),
777                 eq(true),
778                 eq(0),
779                 eq(false),
780             )
781         assertThat(mediaDataCaptor.value.resumption).isTrue()
782         assertThat(mediaDataCaptor.value.isPlaying).isFalse()
783         verify(logger).logActiveConvertedToResume(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
784     }
785 
786     @Test
testOnNotificationRemoved_twoWithResumptionnull787     fun testOnNotificationRemoved_twoWithResumption() {
788         // GIVEN that the manager has two notifications with resume actions
789         mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
790         mediaDataProcessor.onNotificationAdded(KEY_2, mediaNotification)
791         testScope.assertRunAllReady(foreground = 2, background = 2)
792 
793         verify(listener)
794             .onMediaDataLoaded(
795                 eq(KEY),
796                 eq(null),
797                 capture(mediaDataCaptor),
798                 eq(true),
799                 eq(0),
800                 eq(false),
801             )
802         val data = mediaDataCaptor.value
803         assertThat(data.resumption).isFalse()
804 
805         verify(listener)
806             .onMediaDataLoaded(
807                 eq(KEY_2),
808                 eq(null),
809                 capture(mediaDataCaptor),
810                 eq(true),
811                 eq(0),
812                 eq(false),
813             )
814         val data2 = mediaDataCaptor.value
815         assertThat(data2.resumption).isFalse()
816 
817         mediaDataProcessor.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
818         mediaDataProcessor.onMediaDataLoaded(KEY_2, null, data2.copy(resumeAction = Runnable {}))
819         reset(listener)
820         // WHEN the first is removed
821         mediaDataProcessor.onNotificationRemoved(KEY)
822         // THEN the data is for resumption and the key is migrated to the package name
823         verify(listener)
824             .onMediaDataLoaded(
825                 eq(PACKAGE_NAME),
826                 eq(KEY),
827                 capture(mediaDataCaptor),
828                 eq(true),
829                 eq(0),
830                 eq(false),
831             )
832         assertThat(mediaDataCaptor.value.resumption).isTrue()
833         verify(listener, never()).onMediaDataRemoved(eq(KEY), anyBoolean())
834         // WHEN the second is removed
835         mediaDataProcessor.onNotificationRemoved(KEY_2)
836         // THEN the data is for resumption and the second key is removed
837         verify(listener)
838             .onMediaDataLoaded(
839                 eq(PACKAGE_NAME),
840                 eq(PACKAGE_NAME),
841                 capture(mediaDataCaptor),
842                 eq(true),
843                 eq(0),
844                 eq(false),
845             )
846         assertThat(mediaDataCaptor.value.resumption).isTrue()
847         verify(listener).onMediaDataRemoved(eq(KEY_2), eq(false))
848     }
849 
850     @Test
testOnNotificationRemoved_withResumption_butNotLocalnull851     fun testOnNotificationRemoved_withResumption_butNotLocal() {
852         // GIVEN that the manager has a notification with a resume action, but is not local
853         whenever(playbackInfo.playbackType)
854             .thenReturn(MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE)
855         addNotificationAndLoad()
856         val data = mediaDataCaptor.value
857         val dataRemoteWithResume =
858             data.copy(resumeAction = Runnable {}, playbackLocation = MediaData.PLAYBACK_CAST_LOCAL)
859         mediaDataProcessor.onMediaDataLoaded(KEY, null, dataRemoteWithResume)
860         verify(logger)
861             .logActiveMediaAdded(
862                 anyInt(),
863                 eq(PACKAGE_NAME),
864                 eq(mediaDataCaptor.value.instanceId),
865                 eq(MediaData.PLAYBACK_CAST_LOCAL),
866             )
867 
868         // WHEN the notification is removed
869         mediaDataProcessor.onNotificationRemoved(KEY)
870 
871         // THEN the media data is removed
872         verify(listener).onMediaDataRemoved(eq(KEY), eq(false))
873     }
874 
875     @Test
testOnNotificationRemoved_withResumption_isRemoteAndRemoteAllowednull876     fun testOnNotificationRemoved_withResumption_isRemoteAndRemoteAllowed() {
877         // With the flag enabled to allow remote media to resume
878         fakeFeatureFlags.set(MEDIA_REMOTE_RESUME, true)
879 
880         // GIVEN that the manager has a notification with a resume action, but is not local
881         whenever(controller.metadata).thenReturn(metadataBuilder.build())
882         whenever(playbackInfo.playbackType)
883             .thenReturn(MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE)
884         addNotificationAndLoad()
885         val data = mediaDataCaptor.value
886         val dataRemoteWithResume =
887             data.copy(resumeAction = Runnable {}, playbackLocation = MediaData.PLAYBACK_CAST_LOCAL)
888         mediaDataProcessor.onMediaDataLoaded(KEY, null, dataRemoteWithResume)
889 
890         // WHEN the notification is removed
891         mediaDataProcessor.onNotificationRemoved(KEY)
892 
893         // THEN the media data is converted to a resume state
894         verify(listener)
895             .onMediaDataLoaded(
896                 eq(PACKAGE_NAME),
897                 eq(KEY),
898                 capture(mediaDataCaptor),
899                 eq(true),
900                 eq(0),
901                 eq(false),
902             )
903         assertThat(mediaDataCaptor.value.resumption).isTrue()
904     }
905 
906     @Test
testOnNotificationRemoved_withResumption_isRcnAndRemoteAllowednull907     fun testOnNotificationRemoved_withResumption_isRcnAndRemoteAllowed() {
908         // With the flag enabled to allow remote media to resume
909         fakeFeatureFlags.set(MEDIA_REMOTE_RESUME, true)
910 
911         // GIVEN that the manager has a remote cast notification
912         addNotificationAndLoad(remoteCastNotification)
913         val data = mediaDataCaptor.value
914         assertThat(data.playbackLocation).isEqualTo(MediaData.PLAYBACK_CAST_REMOTE)
915         val dataRemoteWithResume = data.copy(resumeAction = Runnable {})
916         mediaDataProcessor.onMediaDataLoaded(KEY, null, dataRemoteWithResume)
917 
918         // WHEN the RCN is removed
919         mediaDataProcessor.onNotificationRemoved(KEY)
920 
921         // THEN the media data is removed
922         verify(listener).onMediaDataRemoved(eq(KEY), eq(false))
923     }
924 
925     @Test
testOnNotificationRemoved_withResumption_tooManyPlayersnull926     fun testOnNotificationRemoved_withResumption_tooManyPlayers() {
927         // Given the maximum number of resume controls already
928         val desc =
929             MediaDescription.Builder().run {
930                 setTitle(SESSION_TITLE)
931                 build()
932             }
933         for (i in 0..ResumeMediaBrowser.MAX_RESUMPTION_CONTROLS) {
934             addResumeControlAndLoad(desc, "$i:$PACKAGE_NAME")
935             clock.advanceTime(1000)
936         }
937 
938         // And an active, resumable notification
939         addNotificationAndLoad()
940         val data = mediaDataCaptor.value
941         assertThat(data.resumption).isFalse()
942         mediaDataProcessor.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
943 
944         // When the notification is removed
945         mediaDataProcessor.onNotificationRemoved(KEY)
946 
947         // Then it is converted to resumption
948         verify(listener)
949             .onMediaDataLoaded(
950                 eq(PACKAGE_NAME),
951                 eq(KEY),
952                 capture(mediaDataCaptor),
953                 eq(true),
954                 eq(0),
955                 eq(false),
956             )
957         assertThat(mediaDataCaptor.value.resumption).isTrue()
958         assertThat(mediaDataCaptor.value.isPlaying).isFalse()
959 
960         // And the oldest resume control was removed
961         verify(listener).onMediaDataRemoved(eq("0:$PACKAGE_NAME"), eq(false))
962     }
963 
testOnNotificationRemoved_lockDownModenull964     fun testOnNotificationRemoved_lockDownMode() {
965         whenever(keyguardUpdateMonitor.isUserInLockdown(any())).thenReturn(true)
966 
967         addNotificationAndLoad()
968         val data = mediaDataCaptor.value
969         mediaDataProcessor.onNotificationRemoved(KEY)
970 
971         verify(listener, never()).onMediaDataRemoved(eq(KEY), eq(false))
972         verify(logger, never())
973             .logActiveConvertedToResume(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
974         verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
975     }
976 
977     @Test
testAddResumptionControlsnull978     fun testAddResumptionControls() {
979         // WHEN resumption controls are added
980         val desc =
981             MediaDescription.Builder().run {
982                 setTitle(SESSION_TITLE)
983                 build()
984             }
985         val currentTime = clock.elapsedRealtime()
986         addResumeControlAndLoad(desc)
987 
988         val data = mediaDataCaptor.value
989         assertThat(data.resumption).isTrue()
990         assertThat(data.song).isEqualTo(SESSION_TITLE)
991         assertThat(data.app).isEqualTo(APP_NAME)
992         // resume button is a semantic action.
993         assertThat(data.actions).hasSize(0)
994         assertThat(data.semanticActions!!.playOrPause).isNotNull()
995         assertThat(data.lastActive).isAtLeast(currentTime)
996         verify(logger).logResumeMediaAdded(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
997     }
998 
999     @Test
testAddResumptionControls_withExplicitIndicatornull1000     fun testAddResumptionControls_withExplicitIndicator() {
1001         val bundle = Bundle()
1002         // WHEN resumption controls are added with explicit indicator
1003         bundle.putLong(
1004             MediaConstants.METADATA_KEY_IS_EXPLICIT,
1005             MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT,
1006         )
1007         val desc =
1008             MediaDescription.Builder().run {
1009                 setTitle(SESSION_TITLE)
1010                 setExtras(bundle)
1011                 build()
1012             }
1013         val currentTime = clock.elapsedRealtime()
1014         addResumeControlAndLoad(desc)
1015 
1016         val data = mediaDataCaptor.value
1017         assertThat(data.resumption).isTrue()
1018         assertThat(data.song).isEqualTo(SESSION_TITLE)
1019         assertThat(data.app).isEqualTo(APP_NAME)
1020         // resume button is a semantic action.
1021         assertThat(data.actions).hasSize(0)
1022         assertThat(data.semanticActions!!.playOrPause).isNotNull()
1023         assertThat(data.lastActive).isAtLeast(currentTime)
1024         assertThat(data.isExplicit).isTrue()
1025         verify(logger).logResumeMediaAdded(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
1026     }
1027 
1028     @Test
testAddResumptionControls_hasPartialProgressnull1029     fun testAddResumptionControls_hasPartialProgress() {
1030         fakeFeatureFlags.set(MEDIA_RESUME_PROGRESS, true)
1031 
1032         // WHEN resumption controls are added with partial progress
1033         val progress = 0.5
1034         val extras =
1035             Bundle().apply {
1036                 putInt(
1037                     MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
1038                     MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED,
1039                 )
1040                 putDouble(MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress)
1041             }
1042         val desc =
1043             MediaDescription.Builder().run {
1044                 setTitle(SESSION_TITLE)
1045                 setExtras(extras)
1046                 build()
1047             }
1048         addResumeControlAndLoad(desc)
1049 
1050         val data = mediaDataCaptor.value
1051         assertThat(data.resumption).isTrue()
1052         assertThat(data.resumeProgress).isEqualTo(progress)
1053     }
1054 
1055     @Test
testAddResumptionControls_hasNotPlayedProgressnull1056     fun testAddResumptionControls_hasNotPlayedProgress() {
1057         fakeFeatureFlags.set(MEDIA_RESUME_PROGRESS, true)
1058 
1059         // WHEN resumption controls are added that have not been played
1060         val extras =
1061             Bundle().apply {
1062                 putInt(
1063                     MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
1064                     MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED,
1065                 )
1066             }
1067         val desc =
1068             MediaDescription.Builder().run {
1069                 setTitle(SESSION_TITLE)
1070                 setExtras(extras)
1071                 build()
1072             }
1073         addResumeControlAndLoad(desc)
1074 
1075         val data = mediaDataCaptor.value
1076         assertThat(data.resumption).isTrue()
1077         assertThat(data.resumeProgress).isEqualTo(0)
1078     }
1079 
1080     @Test
testAddResumptionControls_hasFullProgressnull1081     fun testAddResumptionControls_hasFullProgress() {
1082         fakeFeatureFlags.set(MEDIA_RESUME_PROGRESS, true)
1083 
1084         // WHEN resumption controls are added with progress info
1085         val extras =
1086             Bundle().apply {
1087                 putInt(
1088                     MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
1089                     MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED,
1090                 )
1091             }
1092         val desc =
1093             MediaDescription.Builder().run {
1094                 setTitle(SESSION_TITLE)
1095                 setExtras(extras)
1096                 build()
1097             }
1098         addResumeControlAndLoad(desc)
1099 
1100         // THEN the media data includes the progress
1101         val data = mediaDataCaptor.value
1102         assertThat(data.resumption).isTrue()
1103         assertThat(data.resumeProgress).isEqualTo(1)
1104     }
1105 
1106     @Test
testAddResumptionControls_hasNoExtrasnull1107     fun testAddResumptionControls_hasNoExtras() {
1108         fakeFeatureFlags.set(MEDIA_RESUME_PROGRESS, true)
1109 
1110         // WHEN resumption controls are added that do not have any extras
1111         val desc =
1112             MediaDescription.Builder().run {
1113                 setTitle(SESSION_TITLE)
1114                 build()
1115             }
1116         addResumeControlAndLoad(desc)
1117 
1118         // Resume progress is null
1119         val data = mediaDataCaptor.value
1120         assertThat(data.resumption).isTrue()
1121         assertThat(data.resumeProgress).isEqualTo(null)
1122     }
1123 
1124     @Test
testAddResumptionControls_hasEmptyTitlenull1125     fun testAddResumptionControls_hasEmptyTitle() {
1126         fakeFeatureFlags.set(MEDIA_RESUME_PROGRESS, true)
1127 
1128         // WHEN resumption controls are added that have empty title
1129         val desc =
1130             MediaDescription.Builder().run {
1131                 setTitle(SESSION_EMPTY_TITLE)
1132                 build()
1133             }
1134         mediaDataProcessor.addResumptionControls(
1135             USER_ID,
1136             desc,
1137             Runnable {},
1138             session.sessionToken,
1139             APP_NAME,
1140             pendingIntent,
1141             PACKAGE_NAME,
1142         )
1143 
1144         // Resumption controls are not added.
1145         testScope.assertRunAllReady(foreground = 0, background = 1)
1146         verify(listener, never())
1147             .onMediaDataLoaded(
1148                 eq(PACKAGE_NAME),
1149                 eq(null),
1150                 capture(mediaDataCaptor),
1151                 eq(true),
1152                 eq(0),
1153                 eq(false),
1154             )
1155     }
1156 
1157     @Test
testAddResumptionControls_hasBlankTitlenull1158     fun testAddResumptionControls_hasBlankTitle() {
1159         fakeFeatureFlags.set(MEDIA_RESUME_PROGRESS, true)
1160 
1161         // WHEN resumption controls are added that have a blank title
1162         val desc =
1163             MediaDescription.Builder().run {
1164                 setTitle(SESSION_BLANK_TITLE)
1165                 build()
1166             }
1167         mediaDataProcessor.addResumptionControls(
1168             USER_ID,
1169             desc,
1170             Runnable {},
1171             session.sessionToken,
1172             APP_NAME,
1173             pendingIntent,
1174             PACKAGE_NAME,
1175         )
1176 
1177         // Resumption controls are not added.
1178         testScope.assertRunAllReady(foreground = 0, background = 1)
1179         verify(listener, never())
1180             .onMediaDataLoaded(
1181                 eq(PACKAGE_NAME),
1182                 eq(null),
1183                 capture(mediaDataCaptor),
1184                 eq(true),
1185                 eq(0),
1186                 eq(false),
1187             )
1188     }
1189 
1190     @Test
testResumptionDisabled_dismissesResumeControlsnull1191     fun testResumptionDisabled_dismissesResumeControls() {
1192         // WHEN there are resume controls and resumption is switched off
1193         val desc =
1194             MediaDescription.Builder().run {
1195                 setTitle(SESSION_TITLE)
1196                 build()
1197             }
1198         addResumeControlAndLoad(desc)
1199 
1200         val data = mediaDataCaptor.value
1201         mediaDataProcessor.setMediaResumptionEnabled(false)
1202 
1203         // THEN the resume controls are dismissed
1204         verify(listener).onMediaDataRemoved(eq(PACKAGE_NAME), eq(false))
1205         verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
1206     }
1207 
1208     @Test
testDismissMedia_listenerCallednull1209     fun testDismissMedia_listenerCalled() {
1210         addNotificationAndLoad()
1211         val data = mediaDataCaptor.value
1212         val removed = mediaDataProcessor.dismissMediaData(KEY, 0L, true)
1213         assertThat(removed).isTrue()
1214 
1215         foregroundExecutor.advanceClockToLast()
1216         foregroundExecutor.runAllReady()
1217 
1218         verify(listener).onMediaDataRemoved(eq(KEY), eq(true))
1219         verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
1220     }
1221 
1222     @Test
testDismissMedia_keyDoesNotExist_returnsFalsenull1223     fun testDismissMedia_keyDoesNotExist_returnsFalse() {
1224         val removed = mediaDataProcessor.dismissMediaData(KEY, 0L, true)
1225         assertThat(removed).isFalse()
1226     }
1227 
1228     @Test
testBadArtwork_doesNotUsenull1229     fun testBadArtwork_doesNotUse() {
1230         // WHEN notification has a too-small artwork
1231         val artwork = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
1232         val notif =
1233             SbnBuilder().run {
1234                 setPkg(PACKAGE_NAME)
1235                 modifyNotification(context).also {
1236                     it.setSmallIcon(android.R.drawable.ic_media_pause)
1237                     it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
1238                     it.setLargeIcon(artwork)
1239                 }
1240                 build()
1241             }
1242         mediaDataProcessor.onNotificationAdded(KEY, notif)
1243 
1244         // THEN it still loads
1245         testScope.assertRunAllReady(foreground = 1, background = 1)
1246         verify(listener)
1247             .onMediaDataLoaded(
1248                 eq(KEY),
1249                 eq(null),
1250                 capture(mediaDataCaptor),
1251                 eq(true),
1252                 eq(0),
1253                 eq(false),
1254             )
1255     }
1256 
1257     @Test
testOnSmartspaceMediaDataLoaded_hasNewValidMediaTarget_callsListenernull1258     fun testOnSmartspaceMediaDataLoaded_hasNewValidMediaTarget_callsListener() {
1259         smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
1260         verify(logger).getNewInstanceId()
1261         val instanceId = instanceIdSequence.lastInstanceId
1262 
1263         verify(listener)
1264             .onSmartspaceMediaDataLoaded(
1265                 eq(KEY_MEDIA_SMARTSPACE),
1266                 eq(
1267                     SmartspaceMediaData(
1268                         targetId = KEY_MEDIA_SMARTSPACE,
1269                         isActive = true,
1270                         packageName = PACKAGE_NAME,
1271                         cardAction = mediaSmartspaceBaseAction,
1272                         recommendations = validRecommendationList,
1273                         dismissIntent = DISMISS_INTENT,
1274                         headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME,
1275                         instanceId = InstanceId.fakeInstanceId(instanceId),
1276                         expiryTimeMs = SMARTSPACE_EXPIRY_TIME,
1277                     )
1278                 ),
1279                 eq(false),
1280             )
1281     }
1282 
1283     @Test
testOnSmartspaceMediaDataLoaded_hasNewInvalidMediaTarget_callsListenernull1284     fun testOnSmartspaceMediaDataLoaded_hasNewInvalidMediaTarget_callsListener() {
1285         whenever(mediaSmartspaceTarget.iconGrid).thenReturn(listOf())
1286         smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
1287         verify(logger).getNewInstanceId()
1288         val instanceId = instanceIdSequence.lastInstanceId
1289 
1290         verify(listener)
1291             .onSmartspaceMediaDataLoaded(
1292                 eq(KEY_MEDIA_SMARTSPACE),
1293                 eq(
1294                     SmartspaceMediaData(
1295                         targetId = KEY_MEDIA_SMARTSPACE,
1296                         isActive = true,
1297                         dismissIntent = DISMISS_INTENT,
1298                         headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME,
1299                         instanceId = InstanceId.fakeInstanceId(instanceId),
1300                         expiryTimeMs = SMARTSPACE_EXPIRY_TIME,
1301                     )
1302                 ),
1303                 eq(false),
1304             )
1305     }
1306 
1307     @Test
testOnSmartspaceMediaDataLoaded_hasNullIntent_callsListenernull1308     fun testOnSmartspaceMediaDataLoaded_hasNullIntent_callsListener() {
1309         val recommendationExtras =
1310             Bundle().apply {
1311                 putString("package_name", PACKAGE_NAME)
1312                 putParcelable("dismiss_intent", null)
1313             }
1314         whenever(mediaSmartspaceBaseAction.extras).thenReturn(recommendationExtras)
1315         whenever(mediaSmartspaceTarget.baseAction).thenReturn(mediaSmartspaceBaseAction)
1316         whenever(mediaSmartspaceTarget.iconGrid).thenReturn(listOf())
1317 
1318         smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
1319         verify(logger).getNewInstanceId()
1320         val instanceId = instanceIdSequence.lastInstanceId
1321 
1322         verify(listener)
1323             .onSmartspaceMediaDataLoaded(
1324                 eq(KEY_MEDIA_SMARTSPACE),
1325                 eq(
1326                     SmartspaceMediaData(
1327                         targetId = KEY_MEDIA_SMARTSPACE,
1328                         isActive = true,
1329                         dismissIntent = null,
1330                         headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME,
1331                         instanceId = InstanceId.fakeInstanceId(instanceId),
1332                         expiryTimeMs = SMARTSPACE_EXPIRY_TIME,
1333                     )
1334                 ),
1335                 eq(false),
1336             )
1337     }
1338 
1339     @Test
testOnSmartspaceMediaDataLoaded_hasNoneMediaTarget_notCallsListenernull1340     fun testOnSmartspaceMediaDataLoaded_hasNoneMediaTarget_notCallsListener() {
1341         smartspaceMediaDataProvider.onTargetsAvailable(listOf())
1342         verify(logger, never()).getNewInstanceId()
1343         verify(listener, never())
1344             .onSmartspaceMediaDataLoaded(anyObject(), anyObject(), anyBoolean())
1345     }
1346 
1347     @Test
testOnSmartspaceMediaDataLoaded_hasNoneMediaTarget_callsRemoveListenernull1348     fun testOnSmartspaceMediaDataLoaded_hasNoneMediaTarget_callsRemoveListener() {
1349         smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
1350         verify(logger).getNewInstanceId()
1351 
1352         smartspaceMediaDataProvider.onTargetsAvailable(listOf())
1353         uiExecutor.advanceClockToLast()
1354         uiExecutor.runAllReady()
1355 
1356         verify(listener).onSmartspaceMediaDataRemoved(eq(KEY_MEDIA_SMARTSPACE), eq(false))
1357         verifyNoMoreInteractions(logger)
1358     }
1359 
1360     @Test
testOnSmartspaceMediaDataLoaded_persistentEnabled_headphoneTrigger_isActivenull1361     fun testOnSmartspaceMediaDataLoaded_persistentEnabled_headphoneTrigger_isActive() {
1362         fakeFeatureFlags.set(MEDIA_RETAIN_RECOMMENDATIONS, true)
1363         smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
1364         val instanceId = instanceIdSequence.lastInstanceId
1365 
1366         verify(listener)
1367             .onSmartspaceMediaDataLoaded(
1368                 eq(KEY_MEDIA_SMARTSPACE),
1369                 eq(
1370                     SmartspaceMediaData(
1371                         targetId = KEY_MEDIA_SMARTSPACE,
1372                         isActive = true,
1373                         packageName = PACKAGE_NAME,
1374                         cardAction = mediaSmartspaceBaseAction,
1375                         recommendations = validRecommendationList,
1376                         dismissIntent = DISMISS_INTENT,
1377                         headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME,
1378                         instanceId = InstanceId.fakeInstanceId(instanceId),
1379                         expiryTimeMs = SMARTSPACE_EXPIRY_TIME,
1380                     )
1381                 ),
1382                 eq(false),
1383             )
1384     }
1385 
1386     @Test
testOnSmartspaceMediaDataLoaded_persistentEnabled_periodicTrigger_notActivenull1387     fun testOnSmartspaceMediaDataLoaded_persistentEnabled_periodicTrigger_notActive() {
1388         fakeFeatureFlags.set(MEDIA_RETAIN_RECOMMENDATIONS, true)
1389         val extras =
1390             Bundle().apply {
1391                 putString("package_name", PACKAGE_NAME)
1392                 putParcelable("dismiss_intent", DISMISS_INTENT)
1393                 putString(EXTRA_KEY_TRIGGER_SOURCE, EXTRA_VALUE_TRIGGER_PERIODIC)
1394             }
1395         whenever(mediaSmartspaceBaseAction.extras).thenReturn(extras)
1396 
1397         smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
1398         val instanceId = instanceIdSequence.lastInstanceId
1399 
1400         verify(listener)
1401             .onSmartspaceMediaDataLoaded(
1402                 eq(KEY_MEDIA_SMARTSPACE),
1403                 eq(
1404                     SmartspaceMediaData(
1405                         targetId = KEY_MEDIA_SMARTSPACE,
1406                         isActive = false,
1407                         packageName = PACKAGE_NAME,
1408                         cardAction = mediaSmartspaceBaseAction,
1409                         recommendations = validRecommendationList,
1410                         dismissIntent = DISMISS_INTENT,
1411                         headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME,
1412                         instanceId = InstanceId.fakeInstanceId(instanceId),
1413                         expiryTimeMs = SMARTSPACE_EXPIRY_TIME,
1414                     )
1415                 ),
1416                 eq(false),
1417             )
1418     }
1419 
1420     @Test
testOnSmartspaceMediaDataLoaded_persistentEnabled_noTargets_inactivenull1421     fun testOnSmartspaceMediaDataLoaded_persistentEnabled_noTargets_inactive() {
1422         fakeFeatureFlags.set(MEDIA_RETAIN_RECOMMENDATIONS, true)
1423 
1424         smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
1425         val instanceId = instanceIdSequence.lastInstanceId
1426 
1427         smartspaceMediaDataProvider.onTargetsAvailable(listOf())
1428         uiExecutor.advanceClockToLast()
1429         uiExecutor.runAllReady()
1430 
1431         verify(listener)
1432             .onSmartspaceMediaDataLoaded(
1433                 eq(KEY_MEDIA_SMARTSPACE),
1434                 eq(
1435                     SmartspaceMediaData(
1436                         targetId = KEY_MEDIA_SMARTSPACE,
1437                         isActive = false,
1438                         packageName = PACKAGE_NAME,
1439                         cardAction = mediaSmartspaceBaseAction,
1440                         recommendations = validRecommendationList,
1441                         dismissIntent = DISMISS_INTENT,
1442                         headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME,
1443                         instanceId = InstanceId.fakeInstanceId(instanceId),
1444                         expiryTimeMs = SMARTSPACE_EXPIRY_TIME,
1445                     )
1446                 ),
1447                 eq(false),
1448             )
1449         verify(listener, never()).onSmartspaceMediaDataRemoved(eq(KEY_MEDIA_SMARTSPACE), eq(false))
1450     }
1451 
1452     @Test
testSetRecommendationInactive_notifiesListenersnull1453     fun testSetRecommendationInactive_notifiesListeners() {
1454         fakeFeatureFlags.set(MEDIA_RETAIN_RECOMMENDATIONS, true)
1455 
1456         smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
1457         val instanceId = instanceIdSequence.lastInstanceId
1458 
1459         mediaDataProcessor.setRecommendationInactive(KEY_MEDIA_SMARTSPACE)
1460         uiExecutor.advanceClockToLast()
1461         uiExecutor.runAllReady()
1462 
1463         verify(listener)
1464             .onSmartspaceMediaDataLoaded(
1465                 eq(KEY_MEDIA_SMARTSPACE),
1466                 eq(
1467                     SmartspaceMediaData(
1468                         targetId = KEY_MEDIA_SMARTSPACE,
1469                         isActive = false,
1470                         packageName = PACKAGE_NAME,
1471                         cardAction = mediaSmartspaceBaseAction,
1472                         recommendations = validRecommendationList,
1473                         dismissIntent = DISMISS_INTENT,
1474                         headphoneConnectionTimeMillis = SMARTSPACE_CREATION_TIME,
1475                         instanceId = InstanceId.fakeInstanceId(instanceId),
1476                         expiryTimeMs = SMARTSPACE_EXPIRY_TIME,
1477                     )
1478                 ),
1479                 eq(false),
1480             )
1481     }
1482 
1483     @Test
testOnSmartspaceMediaDataLoaded_settingDisabled_doesNothingnull1484     fun testOnSmartspaceMediaDataLoaded_settingDisabled_doesNothing() {
1485         // WHEN media recommendation setting is off
1486         settings.putInt(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, 0)
1487         testScope.runCurrent()
1488 
1489         smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
1490 
1491         // THEN smartspace signal is ignored
1492         verify(listener, never())
1493             .onSmartspaceMediaDataLoaded(anyObject(), anyObject(), anyBoolean())
1494     }
1495 
1496     @Test
testMediaRecommendationDisabled_removesSmartspaceDatanull1497     fun testMediaRecommendationDisabled_removesSmartspaceData() {
1498         // GIVEN a media recommendation card is present
1499         smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
1500         verify(listener)
1501             .onSmartspaceMediaDataLoaded(eq(KEY_MEDIA_SMARTSPACE), anyObject(), anyBoolean())
1502 
1503         // WHEN the media recommendation setting is turned off
1504         settings.putInt(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, 0)
1505         testScope.runCurrent()
1506 
1507         // THEN listeners are notified
1508         uiExecutor.advanceClockToLast()
1509         foregroundExecutor.advanceClockToLast()
1510         uiExecutor.runAllReady()
1511         foregroundExecutor.runAllReady()
1512         verify(listener).onSmartspaceMediaDataRemoved(eq(KEY_MEDIA_SMARTSPACE), eq(true))
1513     }
1514 
1515     @Test
testOnMediaDataChanged_updatesLastActiveTimenull1516     fun testOnMediaDataChanged_updatesLastActiveTime() {
1517         val currentTime = clock.elapsedRealtime()
1518         addNotificationAndLoad()
1519         assertThat(mediaDataCaptor.value!!.lastActive).isAtLeast(currentTime)
1520     }
1521 
1522     @Test
testOnMediaDataTimedOut_updatesLastActiveTimenull1523     fun testOnMediaDataTimedOut_updatesLastActiveTime() {
1524         // GIVEN that the manager has a notification
1525         mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
1526         testScope.assertRunAllReady(foreground = 1, background = 1)
1527 
1528         // WHEN the notification times out
1529         clock.advanceTime(100)
1530         val currentTime = clock.elapsedRealtime()
1531         mediaDataProcessor.setInactive(KEY, timedOut = true, forceUpdate = true)
1532 
1533         // THEN the last active time is changed
1534         verify(listener)
1535             .onMediaDataLoaded(
1536                 eq(KEY),
1537                 eq(KEY),
1538                 capture(mediaDataCaptor),
1539                 eq(true),
1540                 eq(0),
1541                 eq(false),
1542             )
1543         assertThat(mediaDataCaptor.value.lastActive).isAtLeast(currentTime)
1544     }
1545 
1546     @Test
testOnActiveMediaConverted_updatesLastActiveTimenull1547     fun testOnActiveMediaConverted_updatesLastActiveTime() {
1548         // GIVEN that the manager has a notification with a resume action
1549         addNotificationAndLoad()
1550         val data = mediaDataCaptor.value
1551         val instanceId = data.instanceId
1552         assertThat(data.resumption).isFalse()
1553         mediaDataProcessor.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
1554 
1555         // WHEN the notification is removed
1556         clock.advanceTime(100)
1557         val currentTime = clock.elapsedRealtime()
1558         mediaDataProcessor.onNotificationRemoved(KEY)
1559 
1560         // THEN the last active time is changed
1561         verify(listener)
1562             .onMediaDataLoaded(
1563                 eq(PACKAGE_NAME),
1564                 eq(KEY),
1565                 capture(mediaDataCaptor),
1566                 eq(true),
1567                 eq(0),
1568                 eq(false),
1569             )
1570         assertThat(mediaDataCaptor.value.resumption).isTrue()
1571         assertThat(mediaDataCaptor.value.lastActive).isAtLeast(currentTime)
1572 
1573         // Log as a conversion event, not as a new resume control
1574         verify(logger).logActiveConvertedToResume(anyInt(), eq(PACKAGE_NAME), eq(instanceId))
1575         verify(logger, never()).logResumeMediaAdded(anyInt(), eq(PACKAGE_NAME), any())
1576     }
1577 
1578     @Test
testOnInactiveMediaConverted_doesNotUpdateLastActiveTimenull1579     fun testOnInactiveMediaConverted_doesNotUpdateLastActiveTime() {
1580         // GIVEN that the manager has a notification with a resume action
1581         addNotificationAndLoad()
1582         val data = mediaDataCaptor.value
1583         val instanceId = data.instanceId
1584         assertThat(data.resumption).isFalse()
1585         mediaDataProcessor.onMediaDataLoaded(
1586             KEY,
1587             null,
1588             data.copy(resumeAction = Runnable {}, active = false),
1589         )
1590 
1591         // WHEN the notification is removed
1592         clock.advanceTime(100)
1593         val currentTime = clock.elapsedRealtime()
1594         mediaDataProcessor.onNotificationRemoved(KEY)
1595 
1596         // THEN the last active time is not changed
1597         verify(listener)
1598             .onMediaDataLoaded(
1599                 eq(PACKAGE_NAME),
1600                 eq(KEY),
1601                 capture(mediaDataCaptor),
1602                 eq(true),
1603                 eq(0),
1604                 eq(false),
1605             )
1606         assertThat(mediaDataCaptor.value.resumption).isTrue()
1607         assertThat(mediaDataCaptor.value.lastActive).isLessThan(currentTime)
1608 
1609         // Log as a conversion event, not as a new resume control
1610         verify(logger).logActiveConvertedToResume(anyInt(), eq(PACKAGE_NAME), eq(instanceId))
1611         verify(logger, never()).logResumeMediaAdded(anyInt(), eq(PACKAGE_NAME), any())
1612     }
1613 
1614     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_BUTTON_MEDIA3)
1615     @Test
testTooManyCompactActions_isTruncatednull1616     fun testTooManyCompactActions_isTruncated() {
1617         // GIVEN a notification where too many compact actions were specified
1618         val notif =
1619             SbnBuilder().run {
1620                 setPkg(PACKAGE_NAME)
1621                 modifyNotification(context).also {
1622                     it.setSmallIcon(android.R.drawable.ic_media_pause)
1623                     it.setStyle(
1624                         MediaStyle().apply {
1625                             setMediaSession(session.sessionToken)
1626                             setShowActionsInCompactView(0, 1, 2, 3, 4)
1627                         }
1628                     )
1629                 }
1630                 build()
1631             }
1632 
1633         // WHEN the notification is loaded
1634         mediaDataProcessor.onNotificationAdded(KEY, notif)
1635         testScope.assertRunAllReady(foreground = 1, background = 1)
1636 
1637         // THEN only the first MAX_COMPACT_ACTIONS are actually set
1638         verify(listener)
1639             .onMediaDataLoaded(
1640                 eq(KEY),
1641                 eq(null),
1642                 capture(mediaDataCaptor),
1643                 eq(true),
1644                 eq(0),
1645                 eq(false),
1646             )
1647         assertThat(mediaDataCaptor.value.actionsToShowInCompact.size)
1648             .isEqualTo(MediaDataProcessor.MAX_COMPACT_ACTIONS)
1649     }
1650 
1651     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_BUTTON_MEDIA3)
1652     @Test
testTooManyNotificationActions_isTruncatednull1653     fun testTooManyNotificationActions_isTruncated() {
1654         // GIVEN a notification where too many notification actions are added
1655         val action = Notification.Action(R.drawable.ic_android, "action", null)
1656         val notif =
1657             SbnBuilder().run {
1658                 setPkg(PACKAGE_NAME)
1659                 modifyNotification(context).also {
1660                     it.setSmallIcon(android.R.drawable.ic_media_pause)
1661                     it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
1662                     for (i in 0..MediaDataProcessor.MAX_NOTIFICATION_ACTIONS) {
1663                         it.addAction(action)
1664                     }
1665                 }
1666                 build()
1667             }
1668 
1669         // WHEN the notification is loaded
1670         mediaDataProcessor.onNotificationAdded(KEY, notif)
1671         testScope.assertRunAllReady(foreground = 1, background = 1)
1672 
1673         // THEN only the first MAX_NOTIFICATION_ACTIONS are actually included
1674         verify(listener)
1675             .onMediaDataLoaded(
1676                 eq(KEY),
1677                 eq(null),
1678                 capture(mediaDataCaptor),
1679                 eq(true),
1680                 eq(0),
1681                 eq(false),
1682             )
1683         assertThat(mediaDataCaptor.value.actions.size)
1684             .isEqualTo(MediaDataProcessor.MAX_NOTIFICATION_ACTIONS)
1685     }
1686 
1687     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_BUTTON_MEDIA3)
1688     @Test
testPlaybackActions_noState_usesNotificationnull1689     fun testPlaybackActions_noState_usesNotification() {
1690         val desc = "Notification Action"
1691         whenever(controller.playbackState).thenReturn(null)
1692 
1693         val notifWithAction =
1694             SbnBuilder().run {
1695                 setPkg(PACKAGE_NAME)
1696                 modifyNotification(context).also {
1697                     it.setSmallIcon(android.R.drawable.ic_media_pause)
1698                     it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
1699                     it.addAction(android.R.drawable.ic_media_play, desc, null)
1700                 }
1701                 build()
1702             }
1703         mediaDataProcessor.onNotificationAdded(KEY, notifWithAction)
1704 
1705         testScope.assertRunAllReady(foreground = 1, background = 1)
1706         verify(listener)
1707             .onMediaDataLoaded(
1708                 eq(KEY),
1709                 eq(null),
1710                 capture(mediaDataCaptor),
1711                 eq(true),
1712                 eq(0),
1713                 eq(false),
1714             )
1715 
1716         assertThat(mediaDataCaptor.value!!.semanticActions).isNull()
1717         assertThat(mediaDataCaptor.value!!.actions).hasSize(1)
1718         assertThat(mediaDataCaptor.value!!.actions[0]!!.contentDescription).isEqualTo(desc)
1719     }
1720 
1721     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_BUTTON_MEDIA3)
1722     @Test
testPlaybackActions_hasPrevNextnull1723     fun testPlaybackActions_hasPrevNext() {
1724         val customDesc = arrayOf("custom 1", "custom 2", "custom 3", "custom 4")
1725         val stateActions =
1726             PlaybackState.ACTION_PLAY or
1727                 PlaybackState.ACTION_SKIP_TO_PREVIOUS or
1728                 PlaybackState.ACTION_SKIP_TO_NEXT
1729         val stateBuilder = PlaybackState.Builder().setActions(stateActions)
1730         customDesc.forEach {
1731             stateBuilder.addCustomAction("action: $it", it, android.R.drawable.ic_media_pause)
1732         }
1733         whenever(controller.playbackState).thenReturn(stateBuilder.build())
1734 
1735         addNotificationAndLoad()
1736 
1737         assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
1738         val actions = mediaDataCaptor.value!!.semanticActions!!
1739 
1740         assertThat(actions.playOrPause).isNotNull()
1741         assertThat(actions.playOrPause!!.contentDescription)
1742             .isEqualTo(context.getString(R.string.controls_media_button_play))
1743         actions.playOrPause!!.action!!.run()
1744         verify(transportControls).play()
1745 
1746         assertThat(actions.prevOrCustom).isNotNull()
1747         assertThat(actions.prevOrCustom!!.contentDescription)
1748             .isEqualTo(context.getString(R.string.controls_media_button_prev))
1749         actions.prevOrCustom!!.action!!.run()
1750         verify(transportControls).skipToPrevious()
1751 
1752         assertThat(actions.nextOrCustom).isNotNull()
1753         assertThat(actions.nextOrCustom!!.contentDescription)
1754             .isEqualTo(context.getString(R.string.controls_media_button_next))
1755         actions.nextOrCustom!!.action!!.run()
1756         verify(transportControls).skipToNext()
1757 
1758         assertThat(actions.custom0).isNotNull()
1759         assertThat(actions.custom0!!.contentDescription).isEqualTo(customDesc[0])
1760 
1761         assertThat(actions.custom1).isNotNull()
1762         assertThat(actions.custom1!!.contentDescription).isEqualTo(customDesc[1])
1763     }
1764 
1765     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_BUTTON_MEDIA3)
1766     @Test
testPlaybackActions_noPrevNext_usesCustomnull1767     fun testPlaybackActions_noPrevNext_usesCustom() {
1768         val customDesc = arrayOf("custom 1", "custom 2", "custom 3", "custom 4", "custom 5")
1769         val stateActions = PlaybackState.ACTION_PLAY
1770         val stateBuilder = PlaybackState.Builder().setActions(stateActions)
1771         customDesc.forEach {
1772             stateBuilder.addCustomAction("action: $it", it, android.R.drawable.ic_media_pause)
1773         }
1774         whenever(controller.playbackState).thenReturn(stateBuilder.build())
1775 
1776         addNotificationAndLoad()
1777 
1778         assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
1779         val actions = mediaDataCaptor.value!!.semanticActions!!
1780 
1781         assertThat(actions.playOrPause).isNotNull()
1782         assertThat(actions.playOrPause!!.contentDescription)
1783             .isEqualTo(context.getString(R.string.controls_media_button_play))
1784 
1785         assertThat(actions.prevOrCustom).isNotNull()
1786         assertThat(actions.prevOrCustom!!.contentDescription).isEqualTo(customDesc[0])
1787 
1788         assertThat(actions.nextOrCustom).isNotNull()
1789         assertThat(actions.nextOrCustom!!.contentDescription).isEqualTo(customDesc[1])
1790 
1791         assertThat(actions.custom0).isNotNull()
1792         assertThat(actions.custom0!!.contentDescription).isEqualTo(customDesc[2])
1793 
1794         assertThat(actions.custom1).isNotNull()
1795         assertThat(actions.custom1!!.contentDescription).isEqualTo(customDesc[3])
1796     }
1797 
1798     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_BUTTON_MEDIA3)
1799     @Test
testPlaybackActions_connectingnull1800     fun testPlaybackActions_connecting() {
1801         val stateActions = PlaybackState.ACTION_PLAY
1802         val stateBuilder =
1803             PlaybackState.Builder()
1804                 .setState(PlaybackState.STATE_BUFFERING, 0, 10f)
1805                 .setActions(stateActions)
1806         whenever(controller.playbackState).thenReturn(stateBuilder.build())
1807 
1808         addNotificationAndLoad()
1809 
1810         assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
1811         val actions = mediaDataCaptor.value!!.semanticActions!!
1812 
1813         assertThat(actions.playOrPause).isNotNull()
1814         assertThat(actions.playOrPause!!.contentDescription)
1815             .isEqualTo(context.getString(R.string.controls_media_button_connecting))
1816     }
1817 
1818     @Test
1819     @EnableFlags(Flags.FLAG_MEDIA_CONTROLS_DRAWABLES_REUSE)
postWithPlaybackActions_drawablesReusednull1820     fun postWithPlaybackActions_drawablesReused() {
1821         whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true)
1822         whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true)
1823         val stateActions =
1824             PlaybackState.ACTION_PAUSE or
1825                 PlaybackState.ACTION_SKIP_TO_PREVIOUS or
1826                 PlaybackState.ACTION_SKIP_TO_NEXT
1827         val stateBuilder =
1828             PlaybackState.Builder()
1829                 .setState(PlaybackState.STATE_PLAYING, 0, 10f)
1830                 .setActions(stateActions)
1831         whenever(controller.playbackState).thenReturn(stateBuilder.build())
1832         val userEntries by testScope.collectLastValue(mediaFilterRepository.selectedUserEntries)
1833 
1834         mediaDataProcessor.addInternalListener(mediaDataFilter)
1835         mediaDataFilter.mediaDataProcessor = mediaDataProcessor
1836         addNotificationAndLoad()
1837 
1838         assertThat(userEntries).hasSize(1)
1839         val firstSemanticActions = userEntries?.values?.toList()?.get(0)?.semanticActions!!
1840 
1841         addNotificationAndLoad()
1842 
1843         assertThat(userEntries).hasSize(1)
1844         val secondSemanticActions = userEntries?.values?.toList()?.get(0)?.semanticActions!!
1845         assertThat(secondSemanticActions.nextOrCustom?.icon)
1846             .isEqualTo(firstSemanticActions.nextOrCustom?.icon)
1847         assertThat(secondSemanticActions.prevOrCustom?.icon)
1848             .isEqualTo(firstSemanticActions.prevOrCustom?.icon)
1849     }
1850 
1851     @Test
1852     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_DRAWABLES_REUSE)
postWithPlaybackActions_drawablesNotReusednull1853     fun postWithPlaybackActions_drawablesNotReused() {
1854         whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true)
1855         whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true)
1856         val stateActions =
1857             PlaybackState.ACTION_PAUSE or
1858                 PlaybackState.ACTION_SKIP_TO_PREVIOUS or
1859                 PlaybackState.ACTION_SKIP_TO_NEXT
1860         val stateBuilder =
1861             PlaybackState.Builder()
1862                 .setState(PlaybackState.STATE_PLAYING, 0, 10f)
1863                 .setActions(stateActions)
1864         whenever(controller.playbackState).thenReturn(stateBuilder.build())
1865         val userEntries by testScope.collectLastValue(mediaFilterRepository.selectedUserEntries)
1866 
1867         mediaDataProcessor.addInternalListener(mediaDataFilter)
1868         mediaDataFilter.mediaDataProcessor = mediaDataProcessor
1869         addNotificationAndLoad()
1870 
1871         assertThat(userEntries).hasSize(1)
1872         val firstSemanticActions = userEntries?.values?.toList()?.get(0)?.semanticActions!!
1873 
1874         addNotificationAndLoad()
1875 
1876         assertThat(userEntries).hasSize(1)
1877         val secondSemanticActions = userEntries?.values?.toList()?.get(0)?.semanticActions!!
1878         assertThat(secondSemanticActions.nextOrCustom?.icon)
1879             .isNotEqualTo(firstSemanticActions.nextOrCustom?.icon)
1880         assertThat(secondSemanticActions.prevOrCustom?.icon)
1881             .isNotEqualTo(firstSemanticActions.prevOrCustom?.icon)
1882     }
1883 
1884     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_BUTTON_MEDIA3)
1885     @Test
testPlaybackActions_reservedSpacenull1886     fun testPlaybackActions_reservedSpace() {
1887         val customDesc = arrayOf("custom 1", "custom 2", "custom 3", "custom 4")
1888         val stateActions = PlaybackState.ACTION_PLAY
1889         val stateBuilder = PlaybackState.Builder().setActions(stateActions)
1890         customDesc.forEach {
1891             stateBuilder.addCustomAction("action: $it", it, android.R.drawable.ic_media_pause)
1892         }
1893         val extras =
1894             Bundle().apply {
1895                 putBoolean(MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV, true)
1896                 putBoolean(MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT, true)
1897             }
1898         whenever(controller.playbackState).thenReturn(stateBuilder.build())
1899         whenever(controller.extras).thenReturn(extras)
1900 
1901         addNotificationAndLoad()
1902 
1903         assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
1904         val actions = mediaDataCaptor.value!!.semanticActions!!
1905 
1906         assertThat(actions.playOrPause).isNotNull()
1907         assertThat(actions.playOrPause!!.contentDescription)
1908             .isEqualTo(context.getString(R.string.controls_media_button_play))
1909 
1910         assertThat(actions.prevOrCustom).isNull()
1911         assertThat(actions.nextOrCustom).isNull()
1912 
1913         assertThat(actions.custom0).isNotNull()
1914         assertThat(actions.custom0!!.contentDescription).isEqualTo(customDesc[0])
1915 
1916         assertThat(actions.custom1).isNotNull()
1917         assertThat(actions.custom1!!.contentDescription).isEqualTo(customDesc[1])
1918 
1919         assertThat(actions.reserveNext).isTrue()
1920         assertThat(actions.reservePrev).isTrue()
1921     }
1922 
1923     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_BUTTON_MEDIA3)
1924     @Test
testPlaybackActions_playPause_hasButtonnull1925     fun testPlaybackActions_playPause_hasButton() {
1926         val stateActions = PlaybackState.ACTION_PLAY_PAUSE
1927         val stateBuilder = PlaybackState.Builder().setActions(stateActions)
1928         whenever(controller.playbackState).thenReturn(stateBuilder.build())
1929 
1930         addNotificationAndLoad()
1931 
1932         assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
1933         val actions = mediaDataCaptor.value!!.semanticActions!!
1934 
1935         assertThat(actions.playOrPause).isNotNull()
1936         assertThat(actions.playOrPause!!.contentDescription)
1937             .isEqualTo(context.getString(R.string.controls_media_button_play))
1938         actions.playOrPause!!.action!!.run()
1939         verify(transportControls).play()
1940     }
1941 
1942     @Test
testPlaybackLocationChange_isLoggednull1943     fun testPlaybackLocationChange_isLogged() {
1944         // Media control added for local playback
1945         addNotificationAndLoad()
1946         val instanceId = mediaDataCaptor.value.instanceId
1947 
1948         // Location is updated to local cast
1949         whenever(playbackInfo.playbackType)
1950             .thenReturn(MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE)
1951         addNotificationAndLoad()
1952         verify(logger)
1953             .logPlaybackLocationChange(
1954                 anyInt(),
1955                 eq(PACKAGE_NAME),
1956                 eq(instanceId),
1957                 eq(MediaData.PLAYBACK_CAST_LOCAL),
1958             )
1959 
1960         // update to remote cast
1961         mediaDataProcessor.onNotificationAdded(KEY, remoteCastNotification)
1962         testScope.assertRunAllReady(foreground = 1, background = 1)
1963         verify(logger)
1964             .logPlaybackLocationChange(
1965                 anyInt(),
1966                 eq(SYSTEM_PACKAGE_NAME),
1967                 eq(instanceId),
1968                 eq(MediaData.PLAYBACK_CAST_REMOTE),
1969             )
1970     }
1971 
1972     @Test
testPlaybackStateChange_keyExists_callsListenernull1973     fun testPlaybackStateChange_keyExists_callsListener() {
1974         // Notification has been added
1975         addNotificationAndLoad()
1976 
1977         // Callback gets an updated state
1978         val state = PlaybackState.Builder().setState(PlaybackState.STATE_PLAYING, 0L, 1f).build()
1979         testScope.onStateUpdated(KEY, state)
1980 
1981         // Listener is notified of updated state
1982         verify(listener)
1983             .onMediaDataLoaded(
1984                 eq(KEY),
1985                 eq(KEY),
1986                 capture(mediaDataCaptor),
1987                 eq(true),
1988                 eq(0),
1989                 eq(false),
1990             )
1991         assertThat(mediaDataCaptor.value.isPlaying).isTrue()
1992     }
1993 
1994     @Test
testPlaybackStateChange_keyDoesNotExist_doesNothingnull1995     fun testPlaybackStateChange_keyDoesNotExist_doesNothing() {
1996         val state = PlaybackState.Builder().build()
1997 
1998         // No media added with this key
1999 
2000         testScope.onStateUpdated(KEY, state)
2001         verify(listener, never())
2002             .onMediaDataLoaded(eq(KEY), any(), any(), anyBoolean(), anyInt(), anyBoolean())
2003     }
2004 
2005     @Test
testPlaybackStateChange_keyHasNullToken_doesNothingnull2006     fun testPlaybackStateChange_keyHasNullToken_doesNothing() {
2007         // When we get an update that sets the data's token to null
2008         addNotificationAndLoad()
2009         val data = mediaDataCaptor.value
2010         assertThat(data.resumption).isFalse()
2011         mediaDataProcessor.onMediaDataLoaded(KEY, null, data.copy(token = null))
2012 
2013         // And then get a state update
2014         val state = PlaybackState.Builder().build()
2015 
2016         // Then no changes are made
2017         testScope.onStateUpdated(KEY, state)
2018         verify(listener, never())
2019             .onMediaDataLoaded(eq(KEY), any(), any(), anyBoolean(), anyInt(), anyBoolean())
2020     }
2021 
2022     @Test
testPlaybackState_PauseWhenFlagTrue_keyExists_callsListenernull2023     fun testPlaybackState_PauseWhenFlagTrue_keyExists_callsListener() {
2024         val state = PlaybackState.Builder().setState(PlaybackState.STATE_PAUSED, 0L, 1f).build()
2025         whenever(controller.playbackState).thenReturn(state)
2026 
2027         addNotificationAndLoad()
2028         testScope.onStateUpdated(KEY, state)
2029 
2030         verify(listener)
2031             .onMediaDataLoaded(
2032                 eq(KEY),
2033                 eq(KEY),
2034                 capture(mediaDataCaptor),
2035                 eq(true),
2036                 eq(0),
2037                 eq(false),
2038             )
2039         assertThat(mediaDataCaptor.value.isPlaying).isFalse()
2040         assertThat(mediaDataCaptor.value.semanticActions).isNotNull()
2041     }
2042 
2043     @Test
testPlaybackState_PauseStateAfterAddingResumption_keyExists_callsListenernull2044     fun testPlaybackState_PauseStateAfterAddingResumption_keyExists_callsListener() {
2045         val desc =
2046             MediaDescription.Builder().run {
2047                 setTitle(SESSION_TITLE)
2048                 build()
2049             }
2050         val state =
2051             PlaybackState.Builder()
2052                 .setState(PlaybackState.STATE_PAUSED, 0L, 1f)
2053                 .setActions(PlaybackState.ACTION_PLAY_PAUSE)
2054                 .build()
2055 
2056         // Add resumption controls in order to have semantic actions.
2057         // To make sure that they are not null after changing state.
2058         mediaDataProcessor.addResumptionControls(
2059             USER_ID,
2060             desc,
2061             Runnable {},
2062             session.sessionToken,
2063             APP_NAME,
2064             pendingIntent,
2065             PACKAGE_NAME,
2066         )
2067         testScope.runCurrent()
2068         backgroundExecutor.runAllReady()
2069         foregroundExecutor.runAllReady()
2070 
2071         testScope.onStateUpdated(PACKAGE_NAME, state)
2072 
2073         verify(listener)
2074             .onMediaDataLoaded(
2075                 eq(PACKAGE_NAME),
2076                 eq(PACKAGE_NAME),
2077                 capture(mediaDataCaptor),
2078                 eq(true),
2079                 eq(0),
2080                 eq(false),
2081             )
2082         assertThat(mediaDataCaptor.value.isPlaying).isFalse()
2083         assertThat(mediaDataCaptor.value.semanticActions).isNotNull()
2084     }
2085 
2086     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_BUTTON_MEDIA3)
2087     @Test
testPlaybackStateNull_Pause_keyExists_callsListenernull2088     fun testPlaybackStateNull_Pause_keyExists_callsListener() {
2089         whenever(controller.playbackState).thenReturn(null)
2090         val state =
2091             PlaybackState.Builder()
2092                 .setState(PlaybackState.STATE_PAUSED, 0L, 1f)
2093                 .setActions(PlaybackState.ACTION_PLAY_PAUSE)
2094                 .build()
2095 
2096         addNotificationAndLoad()
2097         testScope.onStateUpdated(KEY, state)
2098 
2099         verify(listener)
2100             .onMediaDataLoaded(
2101                 eq(KEY),
2102                 eq(KEY),
2103                 capture(mediaDataCaptor),
2104                 eq(true),
2105                 eq(0),
2106                 eq(false),
2107             )
2108         assertThat(mediaDataCaptor.value.isPlaying).isFalse()
2109         assertThat(mediaDataCaptor.value.semanticActions).isNull()
2110     }
2111 
2112     @Test
testNoClearNotOngoing_canDismissnull2113     fun testNoClearNotOngoing_canDismiss() {
2114         mediaNotification =
2115             SbnBuilder().run {
2116                 setPkg(PACKAGE_NAME)
2117                 modifyNotification(context).also {
2118                     it.setSmallIcon(android.R.drawable.ic_media_pause)
2119                     it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
2120                     it.setOngoing(false)
2121                     it.setFlag(FLAG_NO_CLEAR, true)
2122                 }
2123                 build()
2124             }
2125         addNotificationAndLoad()
2126         assertThat(mediaDataCaptor.value.isClearable).isTrue()
2127     }
2128 
2129     @Test
testOngoing_cannotDismissnull2130     fun testOngoing_cannotDismiss() {
2131         mediaNotification =
2132             SbnBuilder().run {
2133                 setPkg(PACKAGE_NAME)
2134                 modifyNotification(context).also {
2135                     it.setSmallIcon(android.R.drawable.ic_media_pause)
2136                     it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
2137                     it.setOngoing(true)
2138                 }
2139                 build()
2140             }
2141         addNotificationAndLoad()
2142         assertThat(mediaDataCaptor.value.isClearable).isFalse()
2143     }
2144 
2145     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_BUTTON_MEDIA3)
2146     @Test
testRetain_notifPlayer_notifRemoved_setToResumenull2147     fun testRetain_notifPlayer_notifRemoved_setToResume() {
2148         fakeFeatureFlags.set(MEDIA_RETAIN_SESSIONS, true)
2149 
2150         // When a media control based on notification is added, times out, and then removed
2151         addNotificationAndLoad()
2152         mediaDataProcessor.setInactive(KEY, timedOut = true)
2153         assertThat(mediaDataCaptor.value.active).isFalse()
2154         mediaDataProcessor.onNotificationRemoved(KEY)
2155 
2156         // It is converted to a resume player
2157         verify(listener)
2158             .onMediaDataLoaded(
2159                 eq(PACKAGE_NAME),
2160                 eq(KEY),
2161                 capture(mediaDataCaptor),
2162                 eq(true),
2163                 eq(0),
2164                 eq(false),
2165             )
2166         assertThat(mediaDataCaptor.value.resumption).isTrue()
2167         assertThat(mediaDataCaptor.value.active).isFalse()
2168         verify(logger)
2169             .logActiveConvertedToResume(
2170                 anyInt(),
2171                 eq(PACKAGE_NAME),
2172                 eq(mediaDataCaptor.value.instanceId),
2173             )
2174     }
2175 
2176     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_BUTTON_MEDIA3)
2177     @Test
testRetain_notifPlayer_sessionDestroyed_doesNotChangenull2178     fun testRetain_notifPlayer_sessionDestroyed_doesNotChange() {
2179         fakeFeatureFlags.set(MEDIA_RETAIN_SESSIONS, true)
2180 
2181         // When a media control based on notification is added and times out
2182         addNotificationAndLoad()
2183         mediaDataProcessor.setInactive(KEY, timedOut = true)
2184         assertThat(mediaDataCaptor.value.active).isFalse()
2185 
2186         // and then the session is destroyed
2187         sessionCallbackCaptor.value.invoke(KEY)
2188 
2189         // It remains as a regular player
2190         verify(listener, never()).onMediaDataRemoved(eq(KEY), anyBoolean())
2191         verify(listener, never())
2192             .onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
2193     }
2194 
2195     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_BUTTON_MEDIA3)
2196     @Test
testRetain_notifPlayer_removeWhileActive_fullyRemovednull2197     fun testRetain_notifPlayer_removeWhileActive_fullyRemoved() {
2198         fakeFeatureFlags.set(MEDIA_RETAIN_SESSIONS, true)
2199 
2200         // When a media control based on notification is added and then removed, without timing out
2201         addNotificationAndLoad()
2202         val data = mediaDataCaptor.value
2203         assertThat(data.active).isTrue()
2204         mediaDataProcessor.onNotificationRemoved(KEY)
2205 
2206         // It is fully removed
2207         verify(listener).onMediaDataRemoved(eq(KEY), eq(false))
2208         verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
2209         verify(listener, never())
2210             .onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
2211     }
2212 
2213     @Test
testRetain_canResume_removeWhileActive_setToResumenull2214     fun testRetain_canResume_removeWhileActive_setToResume() {
2215         fakeFeatureFlags.set(MEDIA_RETAIN_SESSIONS, true)
2216 
2217         // When a media control that supports resumption is added
2218         addNotificationAndLoad()
2219         val dataResumable = mediaDataCaptor.value.copy(resumeAction = Runnable {})
2220         mediaDataProcessor.onMediaDataLoaded(KEY, null, dataResumable)
2221 
2222         // And then removed while still active
2223         mediaDataProcessor.onNotificationRemoved(KEY)
2224 
2225         // It is converted to a resume player
2226         verify(listener)
2227             .onMediaDataLoaded(
2228                 eq(PACKAGE_NAME),
2229                 eq(KEY),
2230                 capture(mediaDataCaptor),
2231                 eq(true),
2232                 eq(0),
2233                 eq(false),
2234             )
2235         assertThat(mediaDataCaptor.value.resumption).isTrue()
2236         assertThat(mediaDataCaptor.value.active).isFalse()
2237         verify(logger)
2238             .logActiveConvertedToResume(
2239                 anyInt(),
2240                 eq(PACKAGE_NAME),
2241                 eq(mediaDataCaptor.value.instanceId),
2242             )
2243     }
2244 
2245     @Test
testRetain_sessionPlayer_notifRemoved_doesNotChangenull2246     fun testRetain_sessionPlayer_notifRemoved_doesNotChange() {
2247         fakeFeatureFlags.set(MEDIA_RETAIN_SESSIONS, true)
2248         addPlaybackStateAction()
2249 
2250         // When a media control with PlaybackState actions is added, times out,
2251         // and then the notification is removed
2252         addNotificationAndLoad()
2253         val data = mediaDataCaptor.value
2254         assertThat(data.active).isTrue()
2255         mediaDataProcessor.setInactive(KEY, timedOut = true)
2256         mediaDataProcessor.onNotificationRemoved(KEY)
2257 
2258         // It remains as a regular player
2259         verify(listener, never()).onMediaDataRemoved(eq(KEY), anyBoolean())
2260         verify(listener, never())
2261             .onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
2262     }
2263 
2264     @Test
testRetain_sessionPlayer_sessionDestroyed_setToResumenull2265     fun testRetain_sessionPlayer_sessionDestroyed_setToResume() {
2266         fakeFeatureFlags.set(MEDIA_RETAIN_SESSIONS, true)
2267         addPlaybackStateAction()
2268 
2269         // When a media control with PlaybackState actions is added, times out,
2270         // and then the session is destroyed
2271         addNotificationAndLoad()
2272         val data = mediaDataCaptor.value
2273         assertThat(data.active).isTrue()
2274         mediaDataProcessor.setInactive(KEY, timedOut = true)
2275         sessionCallbackCaptor.value.invoke(KEY)
2276 
2277         // It is converted to a resume player
2278         verify(listener)
2279             .onMediaDataLoaded(
2280                 eq(PACKAGE_NAME),
2281                 eq(KEY),
2282                 capture(mediaDataCaptor),
2283                 eq(true),
2284                 eq(0),
2285                 eq(false),
2286             )
2287         assertThat(mediaDataCaptor.value.resumption).isTrue()
2288         assertThat(mediaDataCaptor.value.active).isFalse()
2289         verify(logger)
2290             .logActiveConvertedToResume(
2291                 anyInt(),
2292                 eq(PACKAGE_NAME),
2293                 eq(mediaDataCaptor.value.instanceId),
2294             )
2295     }
2296 
2297     @Test
testRetain_sessionPlayer_destroyedWhileActive_noResume_fullyRemovednull2298     fun testRetain_sessionPlayer_destroyedWhileActive_noResume_fullyRemoved() {
2299         fakeFeatureFlags.set(MEDIA_RETAIN_SESSIONS, true)
2300         addPlaybackStateAction()
2301 
2302         // When a media control using session actions is added, and then the session is destroyed
2303         // without timing out first
2304         addNotificationAndLoad()
2305         val data = mediaDataCaptor.value
2306         assertThat(data.active).isTrue()
2307         sessionCallbackCaptor.value.invoke(KEY)
2308 
2309         // It is fully removed
2310         verify(listener).onMediaDataRemoved(eq(KEY), eq(false))
2311         verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
2312         verify(listener, never())
2313             .onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
2314     }
2315 
2316     @Test
testRetain_sessionPlayer_canResume_destroyedWhileActive_setToResumenull2317     fun testRetain_sessionPlayer_canResume_destroyedWhileActive_setToResume() {
2318         fakeFeatureFlags.set(MEDIA_RETAIN_SESSIONS, true)
2319         addPlaybackStateAction()
2320 
2321         // When a media control using session actions and that does allow resumption is added,
2322         addNotificationAndLoad()
2323         val dataResumable = mediaDataCaptor.value.copy(resumeAction = Runnable {})
2324         mediaDataProcessor.onMediaDataLoaded(KEY, null, dataResumable)
2325 
2326         // And then the session is destroyed without timing out first
2327         sessionCallbackCaptor.value.invoke(KEY)
2328 
2329         // It is converted to a resume player
2330         verify(listener)
2331             .onMediaDataLoaded(
2332                 eq(PACKAGE_NAME),
2333                 eq(KEY),
2334                 capture(mediaDataCaptor),
2335                 eq(true),
2336                 eq(0),
2337                 eq(false),
2338             )
2339         assertThat(mediaDataCaptor.value.resumption).isTrue()
2340         assertThat(mediaDataCaptor.value.active).isFalse()
2341         verify(logger)
2342             .logActiveConvertedToResume(
2343                 anyInt(),
2344                 eq(PACKAGE_NAME),
2345                 eq(mediaDataCaptor.value.instanceId),
2346             )
2347     }
2348 
2349     @Test
testSessionPlayer_sessionDestroyed_noResume_fullyRemovednull2350     fun testSessionPlayer_sessionDestroyed_noResume_fullyRemoved() {
2351         addPlaybackStateAction()
2352 
2353         // When a media control with PlaybackState actions is added, times out,
2354         // and then the session is destroyed
2355         addNotificationAndLoad()
2356         val data = mediaDataCaptor.value
2357         assertThat(data.active).isTrue()
2358         mediaDataProcessor.setInactive(KEY, timedOut = true)
2359         sessionCallbackCaptor.value.invoke(KEY)
2360 
2361         // It is fully removed.
2362         verify(listener).onMediaDataRemoved(eq(KEY), eq(false))
2363         verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
2364         verify(listener, never())
2365             .onMediaDataLoaded(
2366                 eq(PACKAGE_NAME),
2367                 eq(KEY),
2368                 capture(mediaDataCaptor),
2369                 eq(true),
2370                 eq(0),
2371                 eq(false),
2372             )
2373     }
2374 
2375     @Test
testSessionPlayer_destroyedWhileActive_noResume_fullyRemovednull2376     fun testSessionPlayer_destroyedWhileActive_noResume_fullyRemoved() {
2377         addPlaybackStateAction()
2378 
2379         // When a media control using session actions is added, and then the session is destroyed
2380         // without timing out first
2381         addNotificationAndLoad()
2382         val data = mediaDataCaptor.value
2383         assertThat(data.active).isTrue()
2384         sessionCallbackCaptor.value.invoke(KEY)
2385 
2386         // It is fully removed
2387         verify(listener).onMediaDataRemoved(eq(KEY), eq(false))
2388         verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
2389         verify(listener, never())
2390             .onMediaDataLoaded(eq(PACKAGE_NAME), any(), any(), anyBoolean(), anyInt(), anyBoolean())
2391     }
2392 
2393     @Test
testSessionPlayer_canResume_destroyedWhileActive_setToResumenull2394     fun testSessionPlayer_canResume_destroyedWhileActive_setToResume() {
2395         addPlaybackStateAction()
2396 
2397         // When a media control using session actions and that does allow resumption is added,
2398         addNotificationAndLoad()
2399         val dataResumable = mediaDataCaptor.value.copy(resumeAction = Runnable {})
2400         mediaDataProcessor.onMediaDataLoaded(KEY, null, dataResumable)
2401 
2402         // And then the session is destroyed without timing out first
2403         sessionCallbackCaptor.value.invoke(KEY)
2404 
2405         // It is converted to a resume player
2406         verify(listener)
2407             .onMediaDataLoaded(
2408                 eq(PACKAGE_NAME),
2409                 eq(KEY),
2410                 capture(mediaDataCaptor),
2411                 eq(true),
2412                 eq(0),
2413                 eq(false),
2414             )
2415         assertThat(mediaDataCaptor.value.resumption).isTrue()
2416         assertThat(mediaDataCaptor.value.active).isFalse()
2417         verify(logger)
2418             .logActiveConvertedToResume(
2419                 anyInt(),
2420                 eq(PACKAGE_NAME),
2421                 eq(mediaDataCaptor.value.instanceId),
2422             )
2423     }
2424 
2425     @Test
testSessionDestroyed_noNotificationKey_stillRemovednull2426     fun testSessionDestroyed_noNotificationKey_stillRemoved() {
2427         fakeFeatureFlags.set(MEDIA_RETAIN_SESSIONS, true)
2428 
2429         // When a notiifcation is added and then removed before it is fully processed
2430         mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
2431         backgroundExecutor.runAllReady()
2432         mediaDataProcessor.onNotificationRemoved(KEY)
2433 
2434         // We still make sure to remove it
2435         verify(listener).onMediaDataRemoved(eq(KEY), eq(false))
2436     }
2437 
2438     @Test
testResumeMediaLoaded_hasArtPermission_artLoadednull2439     fun testResumeMediaLoaded_hasArtPermission_artLoaded() {
2440         // When resume media is loaded and user/app has permission to access the art URI,
2441         whenever(
2442                 ugm.checkGrantUriPermission_ignoreNonSystem(
2443                     anyInt(),
2444                     any(),
2445                     any(),
2446                     anyInt(),
2447                     anyInt(),
2448                 )
2449             )
2450             .thenReturn(1)
2451         val artwork = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
2452         val uri = Uri.parse("content://example")
2453         whenever(ImageDecoder.createSource(any(), eq(uri))).thenReturn(imageSource)
2454         whenever(ImageDecoder.decodeBitmap(any(), any())).thenReturn(artwork)
2455 
2456         val desc =
2457             MediaDescription.Builder().run {
2458                 setTitle(SESSION_TITLE)
2459                 setIconUri(uri)
2460                 build()
2461             }
2462         addResumeControlAndLoad(desc)
2463 
2464         // Then the artwork is loaded
2465         assertThat(mediaDataCaptor.value.artwork).isNotNull()
2466     }
2467 
2468     @Test
testResumeMediaLoaded_noArtPermission_noArtLoadednull2469     fun testResumeMediaLoaded_noArtPermission_noArtLoaded() {
2470         // When resume media is loaded and user/app does not have permission to access the art URI
2471         whenever(
2472                 ugm.checkGrantUriPermission_ignoreNonSystem(
2473                     anyInt(),
2474                     any(),
2475                     any(),
2476                     anyInt(),
2477                     anyInt(),
2478                 )
2479             )
2480             .thenThrow(SecurityException("Test no permission"))
2481         val artwork = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
2482         val uri = Uri.parse("content://example")
2483         whenever(ImageDecoder.createSource(any(), eq(uri))).thenReturn(imageSource)
2484         whenever(ImageDecoder.decodeBitmap(any(), any())).thenReturn(artwork)
2485 
2486         val desc =
2487             MediaDescription.Builder().run {
2488                 setTitle(SESSION_TITLE)
2489                 setIconUri(uri)
2490                 build()
2491             }
2492         addResumeControlAndLoad(desc)
2493 
2494         // Then the artwork is not loaded
2495         assertThat(mediaDataCaptor.value.artwork).isNull()
2496     }
2497 
2498     @Test
2499     @EnableFlags(Flags.FLAG_MEDIA_CONTROLS_POSTS_OPTIMIZATION)
postDuplicateNotification_doesNotCallListenersnull2500     fun postDuplicateNotification_doesNotCallListeners() {
2501         whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true)
2502         whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true)
2503 
2504         mediaDataProcessor.addInternalListener(mediaDataFilter)
2505         mediaDataFilter.mediaDataProcessor = mediaDataProcessor
2506         addNotificationAndLoad()
2507         reset(listener)
2508         mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
2509 
2510         testScope.assertRunAllReady(foreground = 0, background = 1)
2511         verify(listener, never())
2512             .onMediaDataLoaded(
2513                 eq(KEY),
2514                 eq(KEY),
2515                 capture(mediaDataCaptor),
2516                 eq(true),
2517                 eq(0),
2518                 eq(false),
2519             )
2520         verify(kosmos.mediaLogger).logDuplicateMediaNotification(eq(KEY))
2521     }
2522 
2523     @Test
2524     @DisableFlags(Flags.FLAG_MEDIA_CONTROLS_POSTS_OPTIMIZATION)
postDuplicateNotification_callsListenersnull2525     fun postDuplicateNotification_callsListeners() {
2526         whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true)
2527         whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true)
2528 
2529         mediaDataProcessor.addInternalListener(mediaDataFilter)
2530         mediaDataFilter.mediaDataProcessor = mediaDataProcessor
2531         addNotificationAndLoad()
2532         reset(listener)
2533         mediaDataProcessor.onNotificationAdded(KEY, mediaNotification)
2534         testScope.assertRunAllReady(foreground = 1, background = 1)
2535         verify(listener)
2536             .onMediaDataLoaded(
2537                 eq(KEY),
2538                 eq(KEY),
2539                 capture(mediaDataCaptor),
2540                 eq(true),
2541                 eq(0),
2542                 eq(false),
2543             )
2544         verify(kosmos.mediaLogger, never()).logDuplicateMediaNotification(eq(KEY))
2545     }
2546 
assertRunAllReadynull2547     private fun TestScope.assertRunAllReady(foreground: Int = 0, background: Int = 0) {
2548         runCurrent()
2549         if (Flags.mediaLoadMetadataViaMediaDataLoader()) {
2550             advanceUntilIdle()
2551             // It doesn't make much sense to count tasks when we use coroutines in loader
2552             // so this check is skipped in that scenario.
2553             backgroundExecutor.runAllReady()
2554             foregroundExecutor.runAllReady()
2555         } else {
2556             if (background > 0) {
2557                 assertThat(backgroundExecutor.runAllReady()).isEqualTo(background)
2558             }
2559             if (foreground > 0) {
2560                 assertThat(foregroundExecutor.runAllReady()).isEqualTo(foreground)
2561             }
2562         }
2563     }
2564 
2565     /** Helper function to add a basic media notification and capture the resulting MediaData */
addNotificationAndLoadnull2566     private fun addNotificationAndLoad() {
2567         addNotificationAndLoad(mediaNotification)
2568     }
2569 
2570     /** Helper function to add the given notification and capture the resulting MediaData */
addNotificationAndLoadnull2571     private fun addNotificationAndLoad(sbn: StatusBarNotification) {
2572         mediaDataProcessor.onNotificationAdded(KEY, sbn)
2573         testScope.assertRunAllReady(foreground = 1, background = 1)
2574         verify(listener)
2575             .onMediaDataLoaded(
2576                 eq(KEY),
2577                 eq(null),
2578                 capture(mediaDataCaptor),
2579                 eq(true),
2580                 eq(0),
2581                 eq(false),
2582             )
2583     }
2584 
2585     /** Helper function to set up a PlaybackState with action */
addPlaybackStateActionnull2586     private fun addPlaybackStateAction() {
2587         val stateActions = PlaybackState.ACTION_PLAY_PAUSE
2588         val stateBuilder = PlaybackState.Builder().setActions(stateActions)
2589         stateBuilder.setState(PlaybackState.STATE_PAUSED, 0, 1.0f)
2590         whenever(controller.playbackState).thenReturn(stateBuilder.build())
2591     }
2592 
2593     /** Helper function to add a resumption control and capture the resulting MediaData */
addResumeControlAndLoadnull2594     private fun addResumeControlAndLoad(
2595         desc: MediaDescription,
2596         packageName: String = PACKAGE_NAME,
2597     ) {
2598         mediaDataProcessor.addResumptionControls(
2599             USER_ID,
2600             desc,
2601             Runnable {},
2602             session.sessionToken,
2603             APP_NAME,
2604             pendingIntent,
2605             packageName,
2606         )
2607         testScope.assertRunAllReady(foreground = 1, background = 1)
2608 
2609         verify(listener)
2610             .onMediaDataLoaded(
2611                 eq(packageName),
2612                 eq(null),
2613                 capture(mediaDataCaptor),
2614                 eq(true),
2615                 eq(0),
2616                 eq(false),
2617             )
2618     }
2619 
2620     /** Helper function to update state and run executors */
onStateUpdatednull2621     private fun TestScope.onStateUpdated(key: String, state: PlaybackState) {
2622         stateCallbackCaptor.value.invoke(key, state)
2623         runCurrent()
2624         advanceUntilIdle()
2625     }
2626 }
2627