mirror of
https://github.com/Divested-Mobile/DivestOS-Build.git
synced 2025-01-07 13:48:00 -05:00
af360bc9ea
wgetc873988898
.patch -O telecomm-01.patch wget0fb5786dbf
.patch -O mediaprovider-01.patch wget1a4b9ef510
.patch -O wifi-01.patch wget364a1d9962
.patch -O bluetooth-01.patch wget87a06448b9
.patch -O settings-01.patch wgetaaba724a68
.patch -O settings-02.patch wget507304e1f5
.patch -O native-01.patch wget89489ff5dd
.patch -O base-01.patch wgetd1765c4715
.patch -O base-02.patch wgetcbb1a0ecd6
.patch -O base-03.patch wget4725772c0b
.patch -O base-04.patch wget19747f6923
.patch -O base-05.patch wgete7a1aa9ed0
.patch -O base-06.patch wget922a7860b1
.patch -O base-07.patch wgeted183ed912
.patch -O base-08.patch wgetc6fbe1330a
.patch -O base-09.patch wget9141cac175
.patch -O base-10.patch wget41235bcc67
.patch -O av-01.patch wgeta89f704701
.patch -O av-02.patch wget6d7cd80d77
.patch -O av-03.patch wget75fc175a08
.patch -O av-04.patch wgetb023ec300f
.patch -O av-05.patch wgetc8117d1539
.patch -O av-06.patch wgetf06d23d824
.patch -O av-07.patch wget9c7408ab07
.patch -O av-08.patch wgetcfbfcefb3c
.patch -O launcher-01.patch wget4a27a7f162
.patch -O libxml-01.patch Signed-off-by: Tad <tad@spotco.us>
1169 lines
52 KiB
Diff
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))
|
|
+ }
|
|
+}
|