DivestOS/Patches/LineageOS-20.0/ASB-2023-10/base-02.patch
Tad af360bc9ea
20.0: October ASB picks
wget c873988898.patch -O telecomm-01.patch
wget 0fb5786dbf.patch -O mediaprovider-01.patch
wget 1a4b9ef510.patch -O wifi-01.patch
wget 364a1d9962.patch -O bluetooth-01.patch
wget 87a06448b9.patch -O settings-01.patch
wget aaba724a68.patch -O settings-02.patch
wget 507304e1f5.patch -O native-01.patch
wget 89489ff5dd.patch -O base-01.patch
wget d1765c4715.patch -O base-02.patch
wget cbb1a0ecd6.patch -O base-03.patch
wget 4725772c0b.patch -O base-04.patch
wget 19747f6923.patch -O base-05.patch
wget e7a1aa9ed0.patch -O base-06.patch
wget 922a7860b1.patch -O base-07.patch
wget ed183ed912.patch -O base-08.patch
wget c6fbe1330a.patch -O base-09.patch
wget 9141cac175.patch -O base-10.patch
wget 41235bcc67.patch -O av-01.patch
wget a89f704701.patch -O av-02.patch
wget 6d7cd80d77.patch -O av-03.patch
wget 75fc175a08.patch -O av-04.patch
wget b023ec300f.patch -O av-05.patch
wget c8117d1539.patch -O av-06.patch
wget f06d23d824.patch -O av-07.patch
wget 9c7408ab07.patch -O av-08.patch
wget cfbfcefb3c.patch -O launcher-01.patch
wget 4a27a7f162.patch -O libxml-01.patch

Signed-off-by: Tad <tad@spotco.us>
2023-10-03 14:42:00 -04:00

1169 lines
52 KiB
Diff

From d1765c47157a99ecdc44537b5cadbb9726892967 Mon Sep 17 00:00:00 2001
From: Beth Thibodeau <ethibodeau@google.com>
Date: Tue, 30 May 2023 18:45:47 -0500
Subject: [PATCH] Add placeholder when media control title is blank
When an app posts a media control with no available title, show a
placeholder string with the app name instead
Bug: 274775190
Test: atest MediaDataManagerTest
(cherry picked from https://googleplex-android-review.googlesource.com/q/commit:a0fda1f36d04331c8d60c5540b09b1a30203581b)
Merged-In: Ie406c180af48653595e8e222a15b4dda27de2e0e
Change-Id: Ie406c180af48653595e8e222a15b4dda27de2e0e
---
packages/SystemUI/res/values/strings.xml | 2 +
.../controls/pipeline/MediaDataManager.kt | 8 +-
.../systemui/media/MediaDataManagerTest.kt | 1106 +++++++++++++++++
3 files changed, 1114 insertions(+), 2 deletions(-)
create mode 100644 packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml
index b7036529a733..ac894de4c5d9 100644
--- a/packages/SystemUI/res/values/strings.xml
+++ b/packages/SystemUI/res/values/strings.xml
@@ -2386,6 +2386,8 @@
<string name="controls_media_smartspace_rec_item_no_artist_description">Play <xliff:g id="song_name" example="Daily mix">%1$s</xliff:g> from <xliff:g id="app_label" example="Spotify">%2$s</xliff:g></string>
<!-- Header title for Smartspace recommendation card within media controls. [CHAR_LIMIT=30] -->
<string name="controls_media_smartspace_rec_header">For You</string>
+ <!-- Placeholder title to inform user that an app has posted media controls [CHAR_LIMIT=NONE] -->
+ <string name="controls_media_empty_title"><xliff:g id="app_name" example="Foo Music App">%1$s</xliff:g> is running</string>
<!--- ****** Media tap-to-transfer ****** -->
<!-- Text for a button to undo the media transfer. [CHAR LIMIT=20] -->
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt b/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt
index 525b2fcb8dbc..4e20a24e9add 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/pipeline/MediaDataManager.kt
@@ -786,12 +786,16 @@ class MediaDataManager(
// Song name
var song: CharSequence? = metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
- if (song == null) {
+ if (song.isNullOrBlank()) {
song = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)
}
- if (song == null) {
+ if (song.isNullOrBlank()) {
song = HybridGroupManager.resolveTitle(notif)
}
+ if (song.isNullOrBlank()) {
+ // For apps that don't include a title, add a placeholder
+ song = context.getString(R.string.controls_media_empty_title, appName)
+ }
// Explicit Indicator
var isExplicit = false
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt
new file mode 100644
index 000000000000..52266f983fdd
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt
@@ -0,0 +1,1106 @@
+package com.android.systemui.media
+
+import android.app.Notification
+import android.app.Notification.MediaStyle
+import android.app.PendingIntent
+import android.app.smartspace.SmartspaceAction
+import android.app.smartspace.SmartspaceTarget
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.drawable.Icon
+import android.media.MediaDescription
+import android.media.MediaMetadata
+import android.media.session.MediaController
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.os.Bundle
+import android.provider.Settings
+import android.service.notification.StatusBarNotification
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper.RunWithLooper
+import androidx.media.utils.MediaConstants
+import androidx.test.filters.SmallTest
+import com.android.internal.logging.InstanceId
+import com.android.systemui.InstanceIdSequenceFake
+import com.android.systemui.R
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.broadcast.BroadcastDispatcher
+import com.android.systemui.dump.DumpManager
+import com.android.systemui.plugins.ActivityStarter
+import com.android.systemui.statusbar.SbnBuilder
+import com.android.systemui.tuner.TunerService
+import com.android.systemui.util.concurrency.FakeExecutor
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.argumentCaptor
+import com.android.systemui.util.mockito.capture
+import com.android.systemui.util.mockito.eq
+import com.android.systemui.util.time.FakeSystemClock
+import com.google.common.truth.Truth.assertThat
+import org.junit.After
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.anyBoolean
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Captor
+import org.mockito.Mock
+import org.mockito.Mockito
+import org.mockito.Mockito.never
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.junit.MockitoJUnit
+import org.mockito.Mockito.`when` as whenever
+
+private const val KEY = "KEY"
+private const val KEY_2 = "KEY_2"
+private const val KEY_MEDIA_SMARTSPACE = "MEDIA_SMARTSPACE_ID"
+private const val PACKAGE_NAME = "com.example.app"
+private const val SYSTEM_PACKAGE_NAME = "com.android.systemui"
+private const val APP_NAME = "com.android.systemui.tests"
+private const val SESSION_ARTIST = "artist"
+private const val SESSION_TITLE = "title"
+private const val SESSION_BLANK_TITLE = " "
+private const val SESSION_EMPTY_TITLE = ""
+private const val USER_ID = 0
+private val DISMISS_INTENT = Intent().apply { action = "dismiss" }
+
+private fun <T> anyObject(): T {
+ return Mockito.anyObject<T>()
+}
+
+@SmallTest
+@RunWithLooper(setAsMainLooper = true)
+@RunWith(AndroidTestingRunner::class)
+class MediaDataManagerTest : SysuiTestCase() {
+
+ @JvmField @Rule val mockito = MockitoJUnit.rule()
+ @Mock lateinit var mediaControllerFactory: MediaControllerFactory
+ @Mock lateinit var controller: MediaController
+ @Mock lateinit var transportControls: MediaController.TransportControls
+ @Mock lateinit var playbackInfo: MediaController.PlaybackInfo
+ lateinit var session: MediaSession
+ lateinit var metadataBuilder: MediaMetadata.Builder
+ lateinit var backgroundExecutor: FakeExecutor
+ lateinit var foregroundExecutor: FakeExecutor
+ @Mock lateinit var dumpManager: DumpManager
+ @Mock lateinit var broadcastDispatcher: BroadcastDispatcher
+ @Mock lateinit var mediaTimeoutListener: MediaTimeoutListener
+ @Mock lateinit var mediaResumeListener: MediaResumeListener
+ @Mock lateinit var mediaSessionBasedFilter: MediaSessionBasedFilter
+ @Mock lateinit var mediaDeviceManager: MediaDeviceManager
+ @Mock lateinit var mediaDataCombineLatest: MediaDataCombineLatest
+ @Mock lateinit var mediaDataFilter: MediaDataFilter
+ @Mock lateinit var listener: MediaDataManager.Listener
+ @Mock lateinit var pendingIntent: PendingIntent
+ @Mock lateinit var activityStarter: ActivityStarter
+ lateinit var smartspaceMediaDataProvider: SmartspaceMediaDataProvider
+ @Mock lateinit var mediaSmartspaceTarget: SmartspaceTarget
+ @Mock private lateinit var mediaRecommendationItem: SmartspaceAction
+ lateinit var validRecommendationList: List<SmartspaceAction>
+ @Mock private lateinit var mediaSmartspaceBaseAction: SmartspaceAction
+ @Mock private lateinit var mediaFlags: MediaFlags
+ @Mock private lateinit var logger: MediaUiEventLogger
+ lateinit var mediaDataManager: MediaDataManager
+ lateinit var mediaNotification: StatusBarNotification
+ @Captor lateinit var mediaDataCaptor: ArgumentCaptor<MediaData>
+ private val clock = FakeSystemClock()
+ @Mock private lateinit var tunerService: TunerService
+ @Captor lateinit var tunableCaptor: ArgumentCaptor<TunerService.Tunable>
+
+ private val instanceIdSequence = InstanceIdSequenceFake(1 shl 20)
+
+ private val originalSmartspaceSetting = Settings.Secure.getInt(context.contentResolver,
+ Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, 1)
+
+ @Before
+ fun setup() {
+ foregroundExecutor = FakeExecutor(clock)
+ backgroundExecutor = FakeExecutor(clock)
+ smartspaceMediaDataProvider = SmartspaceMediaDataProvider()
+ Settings.Secure.putInt(context.contentResolver,
+ Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, 1)
+ mediaDataManager = MediaDataManager(
+ context = context,
+ backgroundExecutor = backgroundExecutor,
+ foregroundExecutor = foregroundExecutor,
+ mediaControllerFactory = mediaControllerFactory,
+ broadcastDispatcher = broadcastDispatcher,
+ dumpManager = dumpManager,
+ mediaTimeoutListener = mediaTimeoutListener,
+ mediaResumeListener = mediaResumeListener,
+ mediaSessionBasedFilter = mediaSessionBasedFilter,
+ mediaDeviceManager = mediaDeviceManager,
+ mediaDataCombineLatest = mediaDataCombineLatest,
+ mediaDataFilter = mediaDataFilter,
+ activityStarter = activityStarter,
+ smartspaceMediaDataProvider = smartspaceMediaDataProvider,
+ useMediaResumption = true,
+ useQsMediaPlayer = true,
+ systemClock = clock,
+ tunerService = tunerService,
+ mediaFlags = mediaFlags,
+ logger = logger
+ )
+ verify(tunerService).addTunable(capture(tunableCaptor),
+ eq(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION))
+ session = MediaSession(context, "MediaDataManagerTestSession")
+ mediaNotification = SbnBuilder().run {
+ setPkg(PACKAGE_NAME)
+ modifyNotification(context).also {
+ it.setSmallIcon(android.R.drawable.ic_media_pause)
+ it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
+ }
+ build()
+ }
+ metadataBuilder = MediaMetadata.Builder().apply {
+ putString(MediaMetadata.METADATA_KEY_ARTIST, SESSION_ARTIST)
+ putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_TITLE)
+ }
+ whenever(mediaControllerFactory.create(eq(session.sessionToken))).thenReturn(controller)
+ whenever(controller.transportControls).thenReturn(transportControls)
+ whenever(controller.playbackInfo).thenReturn(playbackInfo)
+ whenever(playbackInfo.playbackType).thenReturn(
+ MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL)
+
+ // This is an ugly hack for now. The mediaSessionBasedFilter is one of the internal
+ // listeners in the internal processing pipeline. It receives events, but ince it is a
+ // mock, it doesn't pass those events along the chain to the external listeners. So, just
+ // treat mediaSessionBasedFilter as a listener for testing.
+ listener = mediaSessionBasedFilter
+
+ val recommendationExtras = Bundle().apply {
+ putString("package_name", PACKAGE_NAME)
+ putParcelable("dismiss_intent", DISMISS_INTENT)
+ }
+ val icon = Icon.createWithResource(context, android.R.drawable.ic_media_play)
+ whenever(mediaSmartspaceBaseAction.extras).thenReturn(recommendationExtras)
+ whenever(mediaSmartspaceTarget.baseAction).thenReturn(mediaSmartspaceBaseAction)
+ whenever(mediaRecommendationItem.extras).thenReturn(recommendationExtras)
+ whenever(mediaRecommendationItem.icon).thenReturn(icon)
+ validRecommendationList = listOf(
+ mediaRecommendationItem, mediaRecommendationItem, mediaRecommendationItem
+ )
+ whenever(mediaSmartspaceTarget.smartspaceTargetId).thenReturn(KEY_MEDIA_SMARTSPACE)
+ whenever(mediaSmartspaceTarget.featureType).thenReturn(SmartspaceTarget.FEATURE_MEDIA)
+ whenever(mediaSmartspaceTarget.iconGrid).thenReturn(validRecommendationList)
+ whenever(mediaSmartspaceTarget.creationTimeMillis).thenReturn(1234L)
+ whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(false)
+ whenever(logger.getNewInstanceId()).thenReturn(instanceIdSequence.newInstanceId())
+ }
+
+ @After
+ fun tearDown() {
+ session.release()
+ mediaDataManager.destroy()
+ Settings.Secure.putInt(context.contentResolver,
+ Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, originalSmartspaceSetting)
+ }
+
+ @Test
+ fun testSetTimedOut_active_deactivatesMedia() {
+ addNotificationAndLoad()
+ val data = mediaDataCaptor.value
+ assertThat(data.active).isTrue()
+
+ mediaDataManager.setTimedOut(KEY, timedOut = true)
+ assertThat(data.active).isFalse()
+ verify(logger).logMediaTimeout(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
+ }
+
+ @Test
+ fun testSetTimedOut_resume_dismissesMedia() {
+ // WHEN resume controls are present, and time out
+ val desc = MediaDescription.Builder().run {
+ setTitle(SESSION_TITLE)
+ build()
+ }
+ mediaDataManager.addResumptionControls(USER_ID, desc, Runnable {}, session.sessionToken,
+ APP_NAME, pendingIntent, PACKAGE_NAME)
+
+ backgroundExecutor.runAllReady()
+ foregroundExecutor.runAllReady()
+ verify(listener).onMediaDataLoaded(eq(PACKAGE_NAME), eq(null), capture(mediaDataCaptor),
+ eq(true), eq(0), eq(false))
+
+ mediaDataManager.setTimedOut(PACKAGE_NAME, timedOut = true)
+ verify(logger).logMediaTimeout(anyInt(), eq(PACKAGE_NAME),
+ eq(mediaDataCaptor.value.instanceId))
+
+ // THEN it is removed and listeners are informed
+ foregroundExecutor.advanceClockToLast()
+ foregroundExecutor.runAllReady()
+ verify(listener).onMediaDataRemoved(PACKAGE_NAME)
+ }
+
+ @Test
+ fun testLoadsMetadataOnBackground() {
+ mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+ assertThat(backgroundExecutor.numPending()).isEqualTo(1)
+ }
+
+ @Test
+ fun testOnMetaDataLoaded_callsListener() {
+ addNotificationAndLoad()
+ verify(logger).logActiveMediaAdded(anyInt(), eq(PACKAGE_NAME),
+ eq(mediaDataCaptor.value.instanceId), eq(MediaData.PLAYBACK_LOCAL))
+ }
+
+ @Test
+ fun testOnMetaDataLoaded_conservesActiveFlag() {
+ whenever(mediaControllerFactory.create(anyObject())).thenReturn(controller)
+ whenever(controller.metadata).thenReturn(metadataBuilder.build())
+ mediaDataManager.addListener(listener)
+ mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ assertThat(mediaDataCaptor.value!!.active).isTrue()
+ }
+
+ @Test
+ fun testOnNotificationAdded_isRcn_markedRemote() {
+ val rcn = SbnBuilder().run {
+ setPkg(SYSTEM_PACKAGE_NAME)
+ modifyNotification(context).also {
+ it.setSmallIcon(android.R.drawable.ic_media_pause)
+ it.setStyle(MediaStyle().apply {
+ setMediaSession(session.sessionToken)
+ setRemotePlaybackInfo("Remote device", 0, null)
+ })
+ }
+ build()
+ }
+
+ mediaDataManager.onNotificationAdded(KEY, rcn)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ assertThat(mediaDataCaptor.value!!.playbackLocation).isEqualTo(
+ MediaData.PLAYBACK_CAST_REMOTE)
+ verify(logger).logActiveMediaAdded(anyInt(), eq(SYSTEM_PACKAGE_NAME),
+ eq(mediaDataCaptor.value.instanceId), eq(MediaData.PLAYBACK_CAST_REMOTE))
+ }
+
+ @Test
+ fun testLoadMediaDataInBg_invalidTokenNoCrash() {
+ val bundle = Bundle()
+ // wrong data type
+ bundle.putParcelable(Notification.EXTRA_MEDIA_SESSION, Bundle())
+ val rcn = SbnBuilder().run {
+ setPkg(SYSTEM_PACKAGE_NAME)
+ modifyNotification(context).also {
+ it.setSmallIcon(android.R.drawable.ic_media_pause)
+ it.addExtras(bundle)
+ it.setStyle(MediaStyle().apply {
+ setRemotePlaybackInfo("Remote device", 0, null)
+ })
+ }
+ build()
+ }
+
+ mediaDataManager.loadMediaDataInBg(KEY, rcn, null)
+ // no crash even though the data structure is incorrect
+ }
+
+ @Test
+ fun testLoadMediaDataInBg_invalidMediaRemoteIntentNoCrash() {
+ val bundle = Bundle()
+ // wrong data type
+ bundle.putParcelable(Notification.EXTRA_MEDIA_REMOTE_INTENT, Bundle())
+ val rcn = SbnBuilder().run {
+ setPkg(SYSTEM_PACKAGE_NAME)
+ modifyNotification(context).also {
+ it.setSmallIcon(android.R.drawable.ic_media_pause)
+ it.addExtras(bundle)
+ it.setStyle(MediaStyle().apply {
+ setMediaSession(session.sessionToken)
+ setRemotePlaybackInfo("Remote device", 0, null)
+ })
+ }
+ build()
+ }
+
+ mediaDataManager.loadMediaDataInBg(KEY, rcn, null)
+ // no crash even though the data structure is incorrect
+ }
+
+ @Test
+ fun testOnNotificationRemoved_callsListener() {
+ addNotificationAndLoad()
+ val data = mediaDataCaptor.value
+ mediaDataManager.onNotificationRemoved(KEY)
+ verify(listener).onMediaDataRemoved(eq(KEY))
+ verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
+ }
+
+ @Test
+ fun testOnNotificationAdded_emptyTitle_hasPlaceholder() {
+ // When the manager has a notification with an empty title
+ whenever(controller.metadata)
+ .thenReturn(
+ metadataBuilder
+ .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_EMPTY_TITLE)
+ .build()
+ )
+ mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+
+ // Then a media control is created with a placeholder title string
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ verify(listener)
+ .onMediaDataLoaded(
+ eq(KEY),
+ eq(null),
+ capture(mediaDataCaptor),
+ eq(true),
+ eq(0),
+ eq(false)
+ )
+ val placeholderTitle = context.getString(R.string.controls_media_empty_title, APP_NAME)
+ assertThat(mediaDataCaptor.value.song).isEqualTo(placeholderTitle)
+ }
+
+ @Test
+ fun testOnNotificationAdded_blankTitle_hasPlaceholder() {
+ // GIVEN that the manager has a notification with a blank title
+ whenever(controller.metadata)
+ .thenReturn(
+ metadataBuilder
+ .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_BLANK_TITLE)
+ .build()
+ )
+ mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+
+ // Then a media control is created with a placeholder title string
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ verify(listener)
+ .onMediaDataLoaded(
+ eq(KEY),
+ eq(null),
+ capture(mediaDataCaptor),
+ eq(true),
+ eq(0),
+ eq(false)
+ )
+ val placeholderTitle = context.getString(R.string.controls_media_empty_title, APP_NAME)
+ assertThat(mediaDataCaptor.value.song).isEqualTo(placeholderTitle)
+ }
+
+ @Test
+ fun testOnNotificationAdded_emptyMetadata_usesNotificationTitle() {
+ // When the app sets the metadata title fields to empty strings, but does include a
+ // non-blank notification title
+ whenever(controller.metadata)
+ .thenReturn(
+ metadataBuilder
+ .putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_EMPTY_TITLE)
+ .putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, SESSION_EMPTY_TITLE)
+ .build()
+ )
+ mediaNotification =
+ SbnBuilder().run {
+ setPkg(PACKAGE_NAME)
+ modifyNotification(context).also {
+ it.setSmallIcon(android.R.drawable.ic_media_pause)
+ it.setContentTitle(SESSION_TITLE)
+ it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
+ }
+ build()
+ }
+ mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+
+ // Then the media control is added using the notification's title
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ verify(listener)
+ .onMediaDataLoaded(
+ eq(KEY),
+ eq(null),
+ capture(mediaDataCaptor),
+ eq(true),
+ eq(0),
+ eq(false)
+ )
+ assertThat(mediaDataCaptor.value.song).isEqualTo(SESSION_TITLE)
+ }
+
+ @Test
+ fun testOnNotificationRemoved_withResumption() {
+ // GIVEN that the manager has a notification with a resume action
+ whenever(controller.metadata).thenReturn(metadataBuilder.build())
+ addNotificationAndLoad()
+ val data = mediaDataCaptor.value
+ assertThat(data.resumption).isFalse()
+ mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
+ // WHEN the notification is removed
+ mediaDataManager.onNotificationRemoved(KEY)
+ // THEN the media data indicates that it is for resumption
+ verify(listener)
+ .onMediaDataLoaded(eq(PACKAGE_NAME), eq(KEY), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ assertThat(mediaDataCaptor.value.resumption).isTrue()
+ assertThat(mediaDataCaptor.value.isPlaying).isFalse()
+ verify(logger).logActiveConvertedToResume(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
+ }
+
+ @Test
+ fun testOnNotificationRemoved_twoWithResumption() {
+ // GIVEN that the manager has two notifications with resume actions
+ whenever(controller.metadata).thenReturn(metadataBuilder.build())
+ mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+ mediaDataManager.onNotificationAdded(KEY_2, mediaNotification)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(2)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(2)
+ verify(listener)
+ .onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ val data = mediaDataCaptor.value
+ assertThat(data.resumption).isFalse()
+ val resumableData = data.copy(resumeAction = Runnable {})
+ mediaDataManager.onMediaDataLoaded(KEY, null, resumableData)
+ mediaDataManager.onMediaDataLoaded(KEY_2, null, resumableData)
+ reset(listener)
+ // WHEN the first is removed
+ mediaDataManager.onNotificationRemoved(KEY)
+ // THEN the data is for resumption and the key is migrated to the package name
+ verify(listener)
+ .onMediaDataLoaded(eq(PACKAGE_NAME), eq(KEY), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ assertThat(mediaDataCaptor.value.resumption).isTrue()
+ verify(listener, never()).onMediaDataRemoved(eq(KEY))
+ // WHEN the second is removed
+ mediaDataManager.onNotificationRemoved(KEY_2)
+ // THEN the data is for resumption and the second key is removed
+ verify(listener)
+ .onMediaDataLoaded(
+ eq(PACKAGE_NAME), eq(PACKAGE_NAME), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ assertThat(mediaDataCaptor.value.resumption).isTrue()
+ verify(listener).onMediaDataRemoved(eq(KEY_2))
+ }
+
+ @Test
+ fun testOnNotificationRemoved_withResumption_butNotLocal() {
+ // GIVEN that the manager has a notification with a resume action, but is not local
+ whenever(controller.metadata).thenReturn(metadataBuilder.build())
+ whenever(playbackInfo.playbackType).thenReturn(
+ MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE)
+ addNotificationAndLoad()
+ val data = mediaDataCaptor.value
+ val dataRemoteWithResume = data.copy(resumeAction = Runnable {},
+ playbackLocation = MediaData.PLAYBACK_CAST_LOCAL)
+ mediaDataManager.onMediaDataLoaded(KEY, null, dataRemoteWithResume)
+ verify(logger).logActiveMediaAdded(anyInt(), eq(PACKAGE_NAME),
+ eq(mediaDataCaptor.value.instanceId), eq(MediaData.PLAYBACK_CAST_LOCAL))
+
+ // WHEN the notification is removed
+ mediaDataManager.onNotificationRemoved(KEY)
+
+ // THEN the media data is removed
+ verify(listener).onMediaDataRemoved(eq(KEY))
+ }
+
+ @Test
+ fun testAddResumptionControls() {
+ // WHEN resumption controls are added
+ val desc = MediaDescription.Builder().run {
+ setTitle(SESSION_TITLE)
+ build()
+ }
+ val currentTime = clock.elapsedRealtime()
+ mediaDataManager.addResumptionControls(USER_ID, desc, Runnable {}, session.sessionToken,
+ APP_NAME, pendingIntent, PACKAGE_NAME)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ // THEN the media data indicates that it is for resumption
+ verify(listener)
+ .onMediaDataLoaded(eq(PACKAGE_NAME), eq(null), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ val data = mediaDataCaptor.value
+ assertThat(data.resumption).isTrue()
+ assertThat(data.song).isEqualTo(SESSION_TITLE)
+ assertThat(data.app).isEqualTo(APP_NAME)
+ assertThat(data.actions).hasSize(1)
+ assertThat(data.semanticActions!!.playOrPause).isNotNull()
+ assertThat(data.lastActive).isAtLeast(currentTime)
+ verify(logger).logResumeMediaAdded(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
+ }
+
+ @Test
+ fun testResumptionDisabled_dismissesResumeControls() {
+ // WHEN there are resume controls and resumption is switched off
+ val desc = MediaDescription.Builder().run {
+ setTitle(SESSION_TITLE)
+ build()
+ }
+ mediaDataManager.addResumptionControls(USER_ID, desc, Runnable {}, session.sessionToken,
+ APP_NAME, pendingIntent, PACKAGE_NAME)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ verify(listener).onMediaDataLoaded(eq(PACKAGE_NAME), eq(null), capture(mediaDataCaptor),
+ eq(true), eq(0), eq(false))
+ val data = mediaDataCaptor.value
+ mediaDataManager.setMediaResumptionEnabled(false)
+
+ // THEN the resume controls are dismissed
+ verify(listener).onMediaDataRemoved(eq(PACKAGE_NAME))
+ verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
+ }
+
+ @Test
+ fun testDismissMedia_listenerCalled() {
+ addNotificationAndLoad()
+ val data = mediaDataCaptor.value
+ val removed = mediaDataManager.dismissMediaData(KEY, 0L)
+ assertThat(removed).isTrue()
+
+ foregroundExecutor.advanceClockToLast()
+ foregroundExecutor.runAllReady()
+
+ verify(listener).onMediaDataRemoved(eq(KEY))
+ verify(logger).logMediaRemoved(anyInt(), eq(PACKAGE_NAME), eq(data.instanceId))
+ }
+
+ @Test
+ fun testDismissMedia_keyDoesNotExist_returnsFalse() {
+ val removed = mediaDataManager.dismissMediaData(KEY, 0L)
+ assertThat(removed).isFalse()
+ }
+
+ @Test
+ fun testBadArtwork_doesNotUse() {
+ // WHEN notification has a too-small artwork
+ val artwork = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
+ val notif = SbnBuilder().run {
+ setPkg(PACKAGE_NAME)
+ modifyNotification(context).also {
+ it.setSmallIcon(android.R.drawable.ic_media_pause)
+ it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
+ it.setLargeIcon(artwork)
+ }
+ build()
+ }
+ mediaDataManager.onNotificationAdded(KEY, notif)
+
+ // THEN it still loads
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ verify(listener)
+ .onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ }
+
+ @Test
+ fun testOnSmartspaceMediaDataLoaded_hasNewValidMediaTarget_callsListener() {
+ smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
+ verify(logger).getNewInstanceId()
+ val instanceId = instanceIdSequence.lastInstanceId
+
+ verify(listener).onSmartspaceMediaDataLoaded(
+ eq(KEY_MEDIA_SMARTSPACE),
+ eq(SmartspaceMediaData(
+ targetId = KEY_MEDIA_SMARTSPACE,
+ isActive = true,
+ packageName = PACKAGE_NAME,
+ cardAction = mediaSmartspaceBaseAction,
+ recommendations = validRecommendationList,
+ dismissIntent = DISMISS_INTENT,
+ headphoneConnectionTimeMillis = 1234L,
+ instanceId = InstanceId.fakeInstanceId(instanceId))),
+ eq(false))
+ }
+
+ @Test
+ fun testOnSmartspaceMediaDataLoaded_hasNewInvalidMediaTarget_callsListener() {
+ whenever(mediaSmartspaceTarget.iconGrid).thenReturn(listOf())
+ smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
+ verify(logger).getNewInstanceId()
+ val instanceId = instanceIdSequence.lastInstanceId
+
+ verify(listener).onSmartspaceMediaDataLoaded(
+ eq(KEY_MEDIA_SMARTSPACE),
+ eq(EMPTY_SMARTSPACE_MEDIA_DATA.copy(
+ targetId = KEY_MEDIA_SMARTSPACE,
+ isActive = true,
+ dismissIntent = DISMISS_INTENT,
+ headphoneConnectionTimeMillis = 1234L,
+ instanceId = InstanceId.fakeInstanceId(instanceId))),
+ eq(false))
+ }
+
+ @Test
+ fun testOnSmartspaceMediaDataLoaded_hasNullIntent_callsListener() {
+ val recommendationExtras = Bundle().apply {
+ putString("package_name", PACKAGE_NAME)
+ putParcelable("dismiss_intent", null)
+ }
+ whenever(mediaSmartspaceBaseAction.extras).thenReturn(recommendationExtras)
+ whenever(mediaSmartspaceTarget.baseAction).thenReturn(mediaSmartspaceBaseAction)
+ whenever(mediaSmartspaceTarget.iconGrid).thenReturn(listOf())
+
+ smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
+ verify(logger).getNewInstanceId()
+ val instanceId = instanceIdSequence.lastInstanceId
+
+ verify(listener).onSmartspaceMediaDataLoaded(
+ eq(KEY_MEDIA_SMARTSPACE),
+ eq(EMPTY_SMARTSPACE_MEDIA_DATA.copy(
+ targetId = KEY_MEDIA_SMARTSPACE,
+ isActive = true,
+ dismissIntent = null,
+ headphoneConnectionTimeMillis = 1234L,
+ instanceId = InstanceId.fakeInstanceId(instanceId))),
+ eq(false))
+ }
+
+ @Test
+ fun testOnSmartspaceMediaDataLoaded_hasNoneMediaTarget_notCallsListener() {
+ smartspaceMediaDataProvider.onTargetsAvailable(listOf())
+ verify(logger, never()).getNewInstanceId()
+ verify(listener, never())
+ .onSmartspaceMediaDataLoaded(anyObject(), anyObject(), anyBoolean())
+ }
+
+ @Ignore("b/233283726")
+ @Test
+ fun testOnSmartspaceMediaDataLoaded_hasNoneMediaTarget_callsRemoveListener() {
+ smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
+ verify(logger).getNewInstanceId()
+
+ smartspaceMediaDataProvider.onTargetsAvailable(listOf())
+ foregroundExecutor.advanceClockToLast()
+ foregroundExecutor.runAllReady()
+
+ verify(listener).onSmartspaceMediaDataRemoved(eq(KEY_MEDIA_SMARTSPACE), eq(false))
+ verifyNoMoreInteractions(logger)
+ }
+
+ @Test
+ fun testOnSmartspaceMediaDataLoaded_settingDisabled_doesNothing() {
+ // WHEN media recommendation setting is off
+ Settings.Secure.putInt(context.contentResolver,
+ Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, 0)
+ tunableCaptor.value.onTuningChanged(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, "0")
+
+ smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
+
+ // THEN smartspace signal is ignored
+ verify(listener, never())
+ .onSmartspaceMediaDataLoaded(anyObject(), anyObject(), anyBoolean())
+ }
+
+ @Ignore("b/229838140")
+ @Test
+ fun testMediaRecommendationDisabled_removesSmartspaceData() {
+ // GIVEN a media recommendation card is present
+ smartspaceMediaDataProvider.onTargetsAvailable(listOf(mediaSmartspaceTarget))
+ verify(listener).onSmartspaceMediaDataLoaded(eq(KEY_MEDIA_SMARTSPACE), anyObject(),
+ anyBoolean())
+
+ // WHEN the media recommendation setting is turned off
+ Settings.Secure.putInt(context.contentResolver,
+ Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, 0)
+ tunableCaptor.value.onTuningChanged(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION, "0")
+
+ // THEN listeners are notified
+ foregroundExecutor.advanceClockToLast()
+ foregroundExecutor.runAllReady()
+ verify(listener).onSmartspaceMediaDataRemoved(eq(KEY_MEDIA_SMARTSPACE), eq(true))
+ }
+
+ @Test
+ fun testOnMediaDataChanged_updatesLastActiveTime() {
+ val currentTime = clock.elapsedRealtime()
+ addNotificationAndLoad()
+ assertThat(mediaDataCaptor.value!!.lastActive).isAtLeast(currentTime)
+ }
+
+ @Test
+ fun testOnMediaDataTimedOut_doesNotUpdateLastActiveTime() {
+ // GIVEN that the manager has a notification
+ mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+
+ // WHEN the notification times out
+ clock.advanceTime(100)
+ val currentTime = clock.elapsedRealtime()
+ mediaDataManager.setTimedOut(KEY, true, true)
+
+ // THEN the last active time is not changed
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(KEY), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ assertThat(mediaDataCaptor.value.lastActive).isLessThan(currentTime)
+ }
+
+ @Test
+ fun testOnActiveMediaConverted_doesNotUpdateLastActiveTime() {
+ // GIVEN that the manager has a notification with a resume action
+ whenever(controller.metadata).thenReturn(metadataBuilder.build())
+ addNotificationAndLoad()
+ val data = mediaDataCaptor.value
+ val instanceId = data.instanceId
+ assertThat(data.resumption).isFalse()
+ mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
+
+ // WHEN the notification is removed
+ clock.advanceTime(100)
+ val currentTime = clock.elapsedRealtime()
+ mediaDataManager.onNotificationRemoved(KEY)
+
+ // THEN the last active time is not changed
+ verify(listener)
+ .onMediaDataLoaded(eq(PACKAGE_NAME), eq(KEY), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ assertThat(mediaDataCaptor.value.resumption).isTrue()
+ assertThat(mediaDataCaptor.value.lastActive).isLessThan(currentTime)
+
+ // Log as a conversion event, not as a new resume control
+ verify(logger).logActiveConvertedToResume(anyInt(), eq(PACKAGE_NAME), eq(instanceId))
+ verify(logger, never()).logResumeMediaAdded(anyInt(), eq(PACKAGE_NAME), any())
+ }
+
+ @Test
+ fun testTooManyCompactActions_isTruncated() {
+ // GIVEN a notification where too many compact actions were specified
+ val notif = SbnBuilder().run {
+ setPkg(PACKAGE_NAME)
+ modifyNotification(context).also {
+ it.setSmallIcon(android.R.drawable.ic_media_pause)
+ it.setStyle(MediaStyle().apply {
+ setMediaSession(session.sessionToken)
+ setShowActionsInCompactView(0, 1, 2, 3, 4)
+ })
+ }
+ build()
+ }
+
+ // WHEN the notification is loaded
+ mediaDataManager.onNotificationAdded(KEY, notif)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+
+ // THEN only the first MAX_COMPACT_ACTIONS are actually set
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ assertThat(mediaDataCaptor.value.actionsToShowInCompact.size).isEqualTo(
+ MediaDataManager.MAX_COMPACT_ACTIONS)
+ }
+
+ @Test
+ fun testTooManyNotificationActions_isTruncated() {
+ // GIVEN a notification where too many notification actions are added
+ val action = Notification.Action(R.drawable.ic_android, "action", null)
+ val notif = SbnBuilder().run {
+ setPkg(PACKAGE_NAME)
+ modifyNotification(context).also {
+ it.setSmallIcon(android.R.drawable.ic_media_pause)
+ it.setStyle(MediaStyle().apply {
+ setMediaSession(session.sessionToken)
+ })
+ for (i in 0..MediaDataManager.MAX_NOTIFICATION_ACTIONS) {
+ it.addAction(action)
+ }
+ }
+ build()
+ }
+
+ // WHEN the notification is loaded
+ mediaDataManager.onNotificationAdded(KEY, notif)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+
+ // THEN only the first MAX_NOTIFICATION_ACTIONS are actually included
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ assertThat(mediaDataCaptor.value.actions.size).isEqualTo(
+ MediaDataManager.MAX_NOTIFICATION_ACTIONS)
+ }
+
+ @Test
+ fun testPlaybackActions_noState_usesNotification() {
+ val desc = "Notification Action"
+ whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
+ whenever(controller.playbackState).thenReturn(null)
+
+ val notifWithAction = SbnBuilder().run {
+ setPkg(PACKAGE_NAME)
+ modifyNotification(context).also {
+ it.setSmallIcon(android.R.drawable.ic_media_pause)
+ it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
+ it.addAction(android.R.drawable.ic_media_play, desc, null)
+ }
+ build()
+ }
+ mediaDataManager.onNotificationAdded(KEY, notifWithAction)
+
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+
+ assertThat(mediaDataCaptor.value!!.semanticActions).isNull()
+ assertThat(mediaDataCaptor.value!!.actions).hasSize(1)
+ assertThat(mediaDataCaptor.value!!.actions[0]!!.contentDescription).isEqualTo(desc)
+ }
+
+ @Test
+ fun testPlaybackActions_hasPrevNext() {
+ val customDesc = arrayOf("custom 1", "custom 2", "custom 3", "custom 4")
+ whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
+ val stateActions = PlaybackState.ACTION_PLAY or
+ PlaybackState.ACTION_SKIP_TO_PREVIOUS or
+ PlaybackState.ACTION_SKIP_TO_NEXT
+ val stateBuilder = PlaybackState.Builder()
+ .setActions(stateActions)
+ customDesc.forEach {
+ stateBuilder.addCustomAction("action: $it", it, android.R.drawable.ic_media_pause)
+ }
+ whenever(controller.playbackState).thenReturn(stateBuilder.build())
+
+ addNotificationAndLoad()
+
+ assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
+ val actions = mediaDataCaptor.value!!.semanticActions!!
+
+ assertThat(actions.playOrPause).isNotNull()
+ assertThat(actions.playOrPause!!.contentDescription).isEqualTo(
+ context.getString(R.string.controls_media_button_play))
+ actions.playOrPause!!.action!!.run()
+ verify(transportControls).play()
+
+ assertThat(actions.prevOrCustom).isNotNull()
+ assertThat(actions.prevOrCustom!!.contentDescription).isEqualTo(
+ context.getString(R.string.controls_media_button_prev))
+ actions.prevOrCustom!!.action!!.run()
+ verify(transportControls).skipToPrevious()
+
+ assertThat(actions.nextOrCustom).isNotNull()
+ assertThat(actions.nextOrCustom!!.contentDescription).isEqualTo(
+ context.getString(R.string.controls_media_button_next))
+ actions.nextOrCustom!!.action!!.run()
+ verify(transportControls).skipToNext()
+
+ assertThat(actions.custom0).isNotNull()
+ assertThat(actions.custom0!!.contentDescription).isEqualTo(customDesc[0])
+
+ assertThat(actions.custom1).isNotNull()
+ assertThat(actions.custom1!!.contentDescription).isEqualTo(customDesc[1])
+ }
+
+ @Test
+ fun testPlaybackActions_noPrevNext_usesCustom() {
+ val customDesc = arrayOf("custom 1", "custom 2", "custom 3", "custom 4", "custom 5")
+ whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
+ val stateActions = PlaybackState.ACTION_PLAY
+ val stateBuilder = PlaybackState.Builder()
+ .setActions(stateActions)
+ customDesc.forEach {
+ stateBuilder.addCustomAction("action: $it", it, android.R.drawable.ic_media_pause)
+ }
+ whenever(controller.playbackState).thenReturn(stateBuilder.build())
+
+ addNotificationAndLoad()
+
+ assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
+ val actions = mediaDataCaptor.value!!.semanticActions!!
+
+ assertThat(actions.playOrPause).isNotNull()
+ assertThat(actions.playOrPause!!.contentDescription).isEqualTo(
+ context.getString(R.string.controls_media_button_play))
+
+ assertThat(actions.prevOrCustom).isNotNull()
+ assertThat(actions.prevOrCustom!!.contentDescription).isEqualTo(customDesc[0])
+
+ assertThat(actions.nextOrCustom).isNotNull()
+ assertThat(actions.nextOrCustom!!.contentDescription).isEqualTo(customDesc[1])
+
+ assertThat(actions.custom0).isNotNull()
+ assertThat(actions.custom0!!.contentDescription).isEqualTo(customDesc[2])
+
+ assertThat(actions.custom1).isNotNull()
+ assertThat(actions.custom1!!.contentDescription).isEqualTo(customDesc[3])
+ }
+
+ @Test
+ fun testPlaybackActions_connecting() {
+ whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
+ val stateActions = PlaybackState.ACTION_PLAY
+ val stateBuilder = PlaybackState.Builder()
+ .setState(PlaybackState.STATE_BUFFERING, 0, 10f)
+ .setActions(stateActions)
+ whenever(controller.playbackState).thenReturn(stateBuilder.build())
+
+ addNotificationAndLoad()
+
+ assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
+ val actions = mediaDataCaptor.value!!.semanticActions!!
+
+ assertThat(actions.playOrPause).isNotNull()
+ assertThat(actions.playOrPause!!.contentDescription).isEqualTo(
+ context.getString(R.string.controls_media_button_connecting))
+ }
+
+ @Test
+ fun testPlaybackActions_reservedSpace() {
+ val customDesc = arrayOf("custom 1", "custom 2", "custom 3", "custom 4")
+ whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
+ val stateActions = PlaybackState.ACTION_PLAY
+ val stateBuilder = PlaybackState.Builder()
+ .setActions(stateActions)
+ customDesc.forEach {
+ stateBuilder.addCustomAction("action: $it", it, android.R.drawable.ic_media_pause)
+ }
+ val extras = Bundle().apply {
+ putBoolean(MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV, true)
+ putBoolean(MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT, true)
+ }
+ whenever(controller.playbackState).thenReturn(stateBuilder.build())
+ whenever(controller.extras).thenReturn(extras)
+
+ addNotificationAndLoad()
+
+ assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
+ val actions = mediaDataCaptor.value!!.semanticActions!!
+
+ assertThat(actions.playOrPause).isNotNull()
+ assertThat(actions.playOrPause!!.contentDescription).isEqualTo(
+ context.getString(R.string.controls_media_button_play))
+
+ assertThat(actions.prevOrCustom).isNull()
+ assertThat(actions.nextOrCustom).isNull()
+
+ assertThat(actions.custom0).isNotNull()
+ assertThat(actions.custom0!!.contentDescription).isEqualTo(customDesc[0])
+
+ assertThat(actions.custom1).isNotNull()
+ assertThat(actions.custom1!!.contentDescription).isEqualTo(customDesc[1])
+
+ assertThat(actions.reserveNext).isTrue()
+ assertThat(actions.reservePrev).isTrue()
+ }
+
+ @Test
+ fun testPlaybackActions_playPause_hasButton() {
+ whenever(mediaFlags.areMediaSessionActionsEnabled(any(), any())).thenReturn(true)
+ val stateActions = PlaybackState.ACTION_PLAY_PAUSE
+ val stateBuilder = PlaybackState.Builder().setActions(stateActions)
+ whenever(controller.playbackState).thenReturn(stateBuilder.build())
+
+ addNotificationAndLoad()
+
+ assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
+ val actions = mediaDataCaptor.value!!.semanticActions!!
+
+ assertThat(actions.playOrPause).isNotNull()
+ assertThat(actions.playOrPause!!.contentDescription).isEqualTo(
+ context.getString(R.string.controls_media_button_play))
+ actions.playOrPause!!.action!!.run()
+ verify(transportControls).play()
+ }
+
+ @Test
+ fun testPlaybackLocationChange_isLogged() {
+ // Media control added for local playback
+ addNotificationAndLoad()
+ val instanceId = mediaDataCaptor.value.instanceId
+
+ // Location is updated to local cast
+ whenever(controller.metadata).thenReturn(metadataBuilder.build())
+ whenever(playbackInfo.playbackType).thenReturn(
+ MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE)
+ addNotificationAndLoad()
+ verify(logger).logPlaybackLocationChange(anyInt(), eq(PACKAGE_NAME),
+ eq(instanceId), eq(MediaData.PLAYBACK_CAST_LOCAL))
+
+ // update to remote cast
+ val rcn = SbnBuilder().run {
+ setPkg(SYSTEM_PACKAGE_NAME) // System package
+ modifyNotification(context).also {
+ it.setSmallIcon(android.R.drawable.ic_media_pause)
+ it.setStyle(MediaStyle().apply {
+ setMediaSession(session.sessionToken)
+ setRemotePlaybackInfo("Remote device", 0, null)
+ })
+ }
+ build()
+ }
+
+ mediaDataManager.onNotificationAdded(KEY, rcn)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ verify(logger).logPlaybackLocationChange(anyInt(), eq(SYSTEM_PACKAGE_NAME),
+ eq(instanceId), eq(MediaData.PLAYBACK_CAST_REMOTE))
+ }
+
+ @Test
+ fun testPlaybackStateChange_keyExists_callsListener() {
+ // Notification has been added
+ addNotificationAndLoad()
+ val callbackCaptor = argumentCaptor<(String, PlaybackState) -> Unit>()
+ verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)
+
+ // Callback gets an updated state
+ val state = PlaybackState.Builder()
+ .setState(PlaybackState.STATE_PLAYING, 0L, 1f)
+ .build()
+ callbackCaptor.value.invoke(KEY, state)
+
+ // Listener is notified of updated state
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(KEY),
+ capture(mediaDataCaptor), eq(true), eq(0), eq(false))
+ assertThat(mediaDataCaptor.value.isPlaying).isTrue()
+ }
+
+ @Test
+ fun testPlaybackStateChange_keyDoesNotExist_doesNothing() {
+ val state = PlaybackState.Builder().build()
+ val callbackCaptor = argumentCaptor<(String, PlaybackState) -> Unit>()
+ verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)
+
+ // No media added with this key
+
+ callbackCaptor.value.invoke(KEY, state)
+ verify(listener, never()).onMediaDataLoaded(eq(KEY), any(), any(), anyBoolean(), anyInt(),
+ anyBoolean())
+ }
+
+ @Test
+ fun testPlaybackStateChange_keyHasNullToken_doesNothing() {
+ // When we get an update that sets the data's token to null
+ whenever(controller.metadata).thenReturn(metadataBuilder.build())
+ addNotificationAndLoad()
+ val data = mediaDataCaptor.value
+ assertThat(data.resumption).isFalse()
+ mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(token = null))
+
+ // And then get a state update
+ val state = PlaybackState.Builder().build()
+ val callbackCaptor = argumentCaptor<(String, PlaybackState) -> Unit>()
+ verify(mediaTimeoutListener).stateCallback = capture(callbackCaptor)
+
+ // Then no changes are made
+ callbackCaptor.value.invoke(KEY, state)
+ verify(listener, never()).onMediaDataLoaded(eq(KEY), any(), any(), anyBoolean(), anyInt(),
+ anyBoolean())
+ }
+
+ /**
+ * Helper function to add a media notification and capture the resulting MediaData
+ */
+ private fun addNotificationAndLoad() {
+ mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
+ eq(0), eq(false))
+ }
+}