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