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