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