Loading packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaControlInteractorTest.kt +135 −11 Original line number Diff line number Diff line Loading @@ -16,10 +16,17 @@ package com.android.systemui.media.controls.domain.interactor import android.app.PendingIntent import android.os.Bundle import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.internal.logging.InstanceId import com.android.systemui.SysuiTestCase import com.android.systemui.activityIntentHelper import com.android.systemui.animation.ActivityTransitionAnimator import com.android.systemui.animation.DialogTransitionAnimator import com.android.systemui.animation.Expandable import com.android.systemui.bluetooth.mockBroadcastDialogController import com.android.systemui.coroutines.collectLastValue import com.android.systemui.kosmos.testScope import com.android.systemui.media.controls.domain.pipeline.MediaDataFilterImpl Loading @@ -28,13 +35,22 @@ import com.android.systemui.media.controls.domain.pipeline.interactor.mediaContr import com.android.systemui.media.controls.domain.pipeline.mediaDataFilter import com.android.systemui.media.controls.shared.model.MediaData import com.android.systemui.media.controls.util.mediaInstanceId import com.android.systemui.media.mediaOutputDialogManager import com.android.systemui.mockActivityIntentHelper import com.android.systemui.plugins.activityStarter import com.android.systemui.statusbar.notificationLockscreenUserManager import com.android.systemui.statusbar.policy.keyguardStateController import com.android.systemui.testKosmos import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.eq import com.android.systemui.util.mockito.mock import com.android.systemui.util.mockito.whenever import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.never import org.mockito.Mockito.verify @SmallTest @RunWith(AndroidJUnit4::class) Loading @@ -44,10 +60,16 @@ class MediaControlInteractorTest : SysuiTestCase() { private val testScope = kosmos.testScope private val mediaDataFilter: MediaDataFilterImpl = kosmos.mediaDataFilter private val activityStarter = kosmos.activityStarter private val keyguardStateController = kosmos.keyguardStateController private val instanceId: InstanceId = kosmos.mediaInstanceId private val notificationLockscreenUserManager = kosmos.notificationLockscreenUserManager private val underTest: MediaControlInteractor = kosmos.mediaControlInteractor private val underTest: MediaControlInteractor = with(kosmos) { activityIntentHelper = mockActivityIntentHelper kosmos.mediaControlInteractor } @Test fun onMediaDataUpdated() = Loading @@ -55,39 +77,141 @@ class MediaControlInteractorTest : SysuiTestCase() { whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true) whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true) val controlModel by collectLastValue(underTest.mediaControl) var mediaData = MediaData(userId = USER_ID, instanceId = instanceId, artist = SESSION_ARTIST) var mediaData = MediaData(userId = USER_ID, instanceId = instanceId, artist = ARTIST) mediaDataFilter.onMediaDataLoaded(KEY, KEY, mediaData) assertThat(controlModel?.instanceId).isEqualTo(instanceId) assertThat(controlModel?.artistName).isEqualTo(SESSION_ARTIST) assertThat(controlModel?.artistName).isEqualTo(ARTIST) mediaData = MediaData(userId = USER_ID, instanceId = instanceId, artist = SESSION_ARTIST_2) mediaData = MediaData(userId = USER_ID, instanceId = instanceId, artist = ARTIST_2) mediaDataFilter.onMediaDataLoaded(KEY, KEY, mediaData) assertThat(controlModel?.instanceId).isEqualTo(instanceId) assertThat(controlModel?.artistName).isEqualTo(SESSION_ARTIST_2) assertThat(controlModel?.artistName).isEqualTo(ARTIST_2) mediaData = MediaData( userId = USER_ID, instanceId = InstanceId.fakeInstanceId(2), artist = SESSION_ARTIST artist = ARTIST ) mediaDataFilter.onMediaDataLoaded(KEY, KEY, mediaData) assertThat(controlModel?.instanceId).isNotEqualTo(mediaData.instanceId) assertThat(controlModel?.artistName).isEqualTo(SESSION_ARTIST_2) assertThat(controlModel?.artistName).isEqualTo(ARTIST_2) } @Test fun startSettings() { underTest.startSettings() verify(activityStarter).startActivity(any(), eq(true)) } @Test fun startClickIntent_showOverLockscreen() { whenever(keyguardStateController.isShowing).thenReturn(true) whenever(kosmos.activityIntentHelper.wouldPendingShowOverLockscreen(any(), any())) .thenReturn(true) val clickIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(true) } val expandable = mock<Expandable>() underTest.startClickIntent(expandable, clickIntent) verify(clickIntent).send(any<Bundle>()) } @Test fun startClickIntent_hideOverLockscreen() { whenever(keyguardStateController.isShowing).thenReturn(false) val clickIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(true) } val expandable = mock<Expandable>() val activityController = mock<ActivityTransitionAnimator.Controller>() whenever(expandable.activityTransitionController(any())).thenReturn(activityController) underTest.startClickIntent(expandable, clickIntent) verify(activityStarter) .postStartActivityDismissingKeyguard(eq(clickIntent), eq(activityController)) } @Test fun startDeviceIntent_showOverLockscreen() { whenever(keyguardStateController.isShowing).thenReturn(true) whenever(kosmos.activityIntentHelper.wouldPendingShowOverLockscreen(any(), any())) .thenReturn(true) val deviceIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(true) } underTest.startDeviceIntent(deviceIntent) verify(deviceIntent).send(any<Bundle>()) } @Test fun startDeviceIntent_intentNotActivity() { whenever(keyguardStateController.isShowing).thenReturn(true) whenever(kosmos.activityIntentHelper.wouldPendingShowOverLockscreen(any(), any())) .thenReturn(true) val deviceIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(false) } underTest.startDeviceIntent(deviceIntent) verify(deviceIntent, never()).send(any<Bundle>()) } @Test fun startDeviceIntent_hideOverLockscreen() { whenever(keyguardStateController.isShowing).thenReturn(false) val deviceIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(true) } underTest.startDeviceIntent(deviceIntent) verify(activityStarter).postStartActivityDismissingKeyguard(eq(deviceIntent)) } @Test fun startMediaOutputDialog() { val expandable = mock<Expandable>() val dialogTransitionController = mock<DialogTransitionAnimator.Controller>() whenever(expandable.dialogTransitionController(any())) .thenReturn(dialogTransitionController) underTest.startMediaOutputDialog(expandable, PACKAGE_NAME) verify(kosmos.mediaOutputDialogManager) .createAndShowWithController(eq(PACKAGE_NAME), eq(true), eq(dialogTransitionController)) } @Test fun startBroadcastDialog() { val expandable = mock<Expandable>() val dialogTransitionController = mock<DialogTransitionAnimator.Controller>() whenever(expandable.dialogTransitionController()).thenReturn(dialogTransitionController) underTest.startBroadcastDialog(expandable, APP_NAME, PACKAGE_NAME) verify(kosmos.mockBroadcastDialogController) .createBroadcastDialogWithController( eq(APP_NAME), eq(PACKAGE_NAME), eq(dialogTransitionController) ) } companion object { private const val USER_ID = 0 private const val KEY = "key" private const val SESSION_ARTIST = "artist" private const val SESSION_ARTIST_2 = "artist2" private const val PACKAGE_NAME = "com.example.app" private const val APP_NAME = "app" private const val ARTIST = "artist" private const val ARTIST_2 = "artist2" } } packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/ui/viewmodel/MediaControlViewModelTest.kt 0 → 100644 +135 −0 Original line number Diff line number Diff line /* * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.media.controls.ui.viewmodel import android.R import android.content.packageManager import android.content.pm.ApplicationInfo import android.media.MediaMetadata import android.media.session.MediaSession import android.media.session.PlaybackState import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.internal.logging.InstanceId import com.android.systemui.SysuiTestCase import com.android.systemui.coroutines.collectLastValue import com.android.systemui.kosmos.testScope import com.android.systemui.media.controls.domain.pipeline.MediaDataFilterImpl import com.android.systemui.media.controls.domain.pipeline.mediaDataFilter import com.android.systemui.media.controls.shared.model.MediaData import com.android.systemui.media.controls.shared.model.MediaDeviceData import com.android.systemui.media.controls.util.mediaInstanceId import com.android.systemui.statusbar.notificationLockscreenUserManager import com.android.systemui.testKosmos import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.eq import com.android.systemui.util.mockito.whenever import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers import org.mockito.Mockito @SmallTest @RunWith(AndroidJUnit4::class) class MediaControlViewModelTest : SysuiTestCase() { private val kosmos = testKosmos() private val testScope = kosmos.testScope private val mediaDataFilter: MediaDataFilterImpl = kosmos.mediaDataFilter private val notificationLockscreenUserManager = kosmos.notificationLockscreenUserManager private val packageManager = kosmos.packageManager private val drawable = context.getDrawable(R.drawable.ic_media_play) private val instanceId: InstanceId = kosmos.mediaInstanceId private val underTest: MediaControlViewModel = kosmos.mediaControlViewModel @Test fun addMediaControl_mediaControlViewModelIsLoaded() = testScope.runTest { whenever(packageManager.getApplicationIcon(Mockito.anyString())).thenReturn(drawable) whenever(packageManager.getApplicationIcon(any(ApplicationInfo::class.java))) .thenReturn(drawable) whenever(packageManager.getApplicationInfo(eq(PACKAGE_NAME), ArgumentMatchers.anyInt())) .thenReturn(ApplicationInfo()) whenever(packageManager.getApplicationLabel(any())).thenReturn(PACKAGE_NAME) whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true) whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true) val playerModel by collectLastValue(underTest.player) context.setMockPackageManager(packageManager) val mediaData = initMediaData() mediaDataFilter.onMediaDataLoaded(KEY, KEY, mediaData) assertThat(playerModel).isNotNull() assertThat(playerModel?.titleName).isEqualTo(TITLE) assertThat(playerModel?.artistName).isEqualTo(ARTIST) assertThat(playerModel?.gutsMenu).isNotNull() assertThat(playerModel?.outputSwitcher).isNotNull() assertThat(playerModel?.actionButtons).isNotNull() assertThat(playerModel?.playTurbulenceNoise).isFalse() } private fun initMediaData(): MediaData { val device = MediaDeviceData(true, null, DEVICE_NAME, null, showBroadcastButton = true) // Create media session val metadataBuilder = MediaMetadata.Builder().apply { putString(MediaMetadata.METADATA_KEY_ARTIST, SESSION_ARTIST) putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_TITLE) } val playbackBuilder = PlaybackState.Builder().apply { setState(PlaybackState.STATE_PAUSED, 6000L, 1f) setActions(PlaybackState.ACTION_PLAY) } val session = MediaSession(context, SESSION_KEY).apply { setMetadata(metadataBuilder.build()) setPlaybackState(playbackBuilder.build()) } session.isActive = true return MediaData( userId = USER_ID, artist = ARTIST, song = TITLE, packageName = PACKAGE, token = session.sessionToken, device = device, instanceId = instanceId ) } companion object { private const val USER_ID = 0 private const val KEY = "key" private const val PACKAGE_NAME = "com.example.app" private const val PACKAGE = "PKG" private const val ARTIST = "ARTIST" private const val TITLE = "TITLE" private const val DEVICE_NAME = "DEVICE_NAME" private const val SESSION_KEY = "SESSION_KEY" private const val SESSION_ARTIST = "SESSION_ARTIST" private const val SESSION_TITLE = "SESSION_TITLE" } } packages/SystemUI/src/com/android/systemui/bluetooth/BroadcastDialogController.java +18 −0 Original line number Diff line number Diff line Loading @@ -56,4 +56,22 @@ public class BroadcastDialogController { broadcastDialog.show(); } } /** Creates a [BroadcastDialog] for the user to switch broadcast or change the output device * * @param currentBroadcastAppName Indicates the APP name currently broadcasting * @param outputPkgName Indicates the output media package name to be switched * @param controller Indicates the dialog controller of the source view. */ public void createBroadcastDialogWithController( String currentBroadcastAppName, String outputPkgName, DialogTransitionAnimator.Controller controller) { SystemUIDialog broadcastDialog = mBroadcastDialogFactory.create( currentBroadcastAppName, outputPkgName).createDialog(); if (controller != null) { mDialogTransitionAnimator.show(broadcastDialog, controller); } else { broadcastDialog.show(); } } } packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt +13 −2 Original line number Diff line number Diff line Loading @@ -590,7 +590,7 @@ class MediaDataProcessor( } /** Dismiss a media entry. Returns false if the key was not found. */ fun dismissMediaData(key: String, delay: Long): Boolean { fun dismissMediaData(key: String, delayMs: Long): Boolean { val existed = mediaDataRepository.mediaEntries.value[key] != null backgroundExecutor.execute { mediaDataRepository.mediaEntries.value[key]?.let { mediaData -> Loading @@ -602,10 +602,21 @@ class MediaDataProcessor( } } } foregroundExecutor.executeDelayed({ removeEntry(key) }, delay) foregroundExecutor.executeDelayed({ removeEntry(key) }, delayMs) return existed } /** Dismiss a media entry. Returns false if the corresponding key was not found. */ fun dismissMediaData(instanceId: InstanceId, delayMs: Long): Boolean { val mediaEntries = mediaDataRepository.mediaEntries.value val filteredEntries = mediaEntries.filter { (_, data) -> data.instanceId == instanceId } return if (filteredEntries.isNotEmpty()) { dismissMediaData(filteredEntries.keys.first(), delayMs) } else { false } } /** * Called whenever the recommendation has been expired or removed by the user. This will remove * the recommendation card entirely from the carousel. Loading packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaControlInteractor.kt +131 −3 Original line number Diff line number Diff line Loading @@ -16,20 +16,49 @@ package com.android.systemui.media.controls.domain.pipeline.interactor import android.app.ActivityOptions import android.app.BroadcastOptions import android.app.PendingIntent import android.content.Context import android.content.Intent import android.media.session.MediaController import android.media.session.MediaSession import android.media.session.PlaybackState import android.provider.Settings import android.util.Log import com.android.internal.jank.Cuj import com.android.internal.logging.InstanceId import com.android.systemui.ActivityIntentHelper import com.android.systemui.animation.DialogCuj import com.android.systemui.animation.DialogTransitionAnimator import com.android.systemui.animation.Expandable import com.android.systemui.bluetooth.BroadcastDialogController import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.media.controls.data.repository.MediaFilterRepository import com.android.systemui.media.controls.domain.pipeline.MediaDataProcessor import com.android.systemui.media.controls.shared.model.MediaControlModel import com.android.systemui.media.controls.shared.model.MediaData import com.android.systemui.media.dialog.MediaOutputDialogManager import com.android.systemui.plugins.ActivityStarter import com.android.systemui.statusbar.NotificationLockscreenUserManager import com.android.systemui.statusbar.policy.KeyguardStateController import com.android.systemui.util.kotlin.pairwiseBy import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map /** Encapsulates business logic for single media control. */ class MediaControlInteractor( instanceId: InstanceId, @Application applicationContext: Context, private val instanceId: InstanceId, repository: MediaFilterRepository, private val mediaDataProcessor: MediaDataProcessor, private val keyguardStateController: KeyguardStateController, private val activityStarter: ActivityStarter, private val activityIntentHelper: ActivityIntentHelper, private val lockscreenUserManager: NotificationLockscreenUserManager, private val mediaOutputDialogManager: MediaOutputDialogManager, private val broadcastDialogController: BroadcastDialogController, ) { val mediaControl: Flow<MediaControlModel?> = Loading @@ -37,8 +66,32 @@ class MediaControlInteractor( .map { entries -> entries[instanceId]?.let { toMediaControlModel(it) } } .distinctUntilChanged() fun removeMediaControl(key: String, delayMs: Long): Boolean { return mediaDataProcessor.dismissMediaData(key, delayMs) val isStartedPlaying: Flow<Boolean> = mediaControl .map { mediaControl -> mediaControl?.token?.let { token -> MediaController(applicationContext, token).playbackState?.let { it.state == PlaybackState.STATE_PLAYING } } ?: false } .pairwiseBy(initialValue = false) { wasPlaying, isPlaying -> !wasPlaying && isPlaying } .distinctUntilChanged() fun removeMediaControl( token: MediaSession.Token?, instanceId: InstanceId, delayMs: Long ): Boolean { val dismissed = mediaDataProcessor.dismissMediaData(instanceId, delayMs) if (!dismissed) { Log.w( TAG, "Manager failed to dismiss media of instanceId=$instanceId, Token uid=${token?.uid}" ) } return dismissed } private fun toMediaControlModel(data: MediaData): MediaControlModel { Loading @@ -53,14 +106,89 @@ class MediaControlInteractor( appName = app, songName = song, artistName = artist, showExplicit = isExplicit, artwork = artwork, deviceData = device, semanticActionButtons = semanticActions, notificationActionButtons = actions, actionsToShowInCollapsed = actionsToShowInCompact, isDismissible = isClearable, isResume = resumption, resumeProgress = resumeProgress, ) } } fun startSettings() { activityStarter.startActivity(SETTINGS_INTENT, /* dismissShade= */ true) } fun startClickIntent(expandable: Expandable, clickIntent: PendingIntent) { if (!launchOverLockscreen(clickIntent)) { activityStarter.postStartActivityDismissingKeyguard( clickIntent, expandable.activityTransitionController(Cuj.CUJ_SHADE_APP_LAUNCH_FROM_MEDIA_PLAYER) ) } } fun startDeviceIntent(deviceIntent: PendingIntent) { if (deviceIntent.isActivity) { if (!launchOverLockscreen(deviceIntent)) { activityStarter.postStartActivityDismissingKeyguard(deviceIntent) } } else { Log.w(TAG, "Device pending intent of instanceId=$instanceId is not an activity.") } } private fun launchOverLockscreen(pendingIntent: PendingIntent): Boolean { val showOverLockscreen = keyguardStateController.isShowing && activityIntentHelper.wouldPendingShowOverLockscreen( pendingIntent, lockscreenUserManager.currentUserId ) if (showOverLockscreen) { try { val options = BroadcastOptions.makeBasic() options.isInteractive = true options.pendingIntentBackgroundActivityStartMode = ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED pendingIntent.send(options.toBundle()) } catch (e: PendingIntent.CanceledException) { Log.e(TAG, "pending intent of $instanceId was canceled") } return true } return false } fun startMediaOutputDialog(expandable: Expandable, packageName: String) { mediaOutputDialogManager.createAndShowWithController( packageName, true, expandable.dialogController() ) } fun startBroadcastDialog(expandable: Expandable, broadcastApp: String, packageName: String) { broadcastDialogController.createBroadcastDialogWithController( broadcastApp, packageName, expandable.dialogTransitionController() ) } private fun Expandable.dialogController(): DialogTransitionAnimator.Controller? { return dialogTransitionController( cuj = DialogCuj(Cuj.CUJ_SHADE_DIALOG_OPEN, MediaOutputDialogManager.INTERACTION_JANK_TAG) ) } companion object { private const val TAG = "MediaControlInteractor" private val SETTINGS_INTENT = Intent(Settings.ACTION_MEDIA_CONTROLS_SETTINGS) } } Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaControlInteractorTest.kt +135 −11 Original line number Diff line number Diff line Loading @@ -16,10 +16,17 @@ package com.android.systemui.media.controls.domain.interactor import android.app.PendingIntent import android.os.Bundle import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.internal.logging.InstanceId import com.android.systemui.SysuiTestCase import com.android.systemui.activityIntentHelper import com.android.systemui.animation.ActivityTransitionAnimator import com.android.systemui.animation.DialogTransitionAnimator import com.android.systemui.animation.Expandable import com.android.systemui.bluetooth.mockBroadcastDialogController import com.android.systemui.coroutines.collectLastValue import com.android.systemui.kosmos.testScope import com.android.systemui.media.controls.domain.pipeline.MediaDataFilterImpl Loading @@ -28,13 +35,22 @@ import com.android.systemui.media.controls.domain.pipeline.interactor.mediaContr import com.android.systemui.media.controls.domain.pipeline.mediaDataFilter import com.android.systemui.media.controls.shared.model.MediaData import com.android.systemui.media.controls.util.mediaInstanceId import com.android.systemui.media.mediaOutputDialogManager import com.android.systemui.mockActivityIntentHelper import com.android.systemui.plugins.activityStarter import com.android.systemui.statusbar.notificationLockscreenUserManager import com.android.systemui.statusbar.policy.keyguardStateController import com.android.systemui.testKosmos import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.eq import com.android.systemui.util.mockito.mock import com.android.systemui.util.mockito.whenever import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.never import org.mockito.Mockito.verify @SmallTest @RunWith(AndroidJUnit4::class) Loading @@ -44,10 +60,16 @@ class MediaControlInteractorTest : SysuiTestCase() { private val testScope = kosmos.testScope private val mediaDataFilter: MediaDataFilterImpl = kosmos.mediaDataFilter private val activityStarter = kosmos.activityStarter private val keyguardStateController = kosmos.keyguardStateController private val instanceId: InstanceId = kosmos.mediaInstanceId private val notificationLockscreenUserManager = kosmos.notificationLockscreenUserManager private val underTest: MediaControlInteractor = kosmos.mediaControlInteractor private val underTest: MediaControlInteractor = with(kosmos) { activityIntentHelper = mockActivityIntentHelper kosmos.mediaControlInteractor } @Test fun onMediaDataUpdated() = Loading @@ -55,39 +77,141 @@ class MediaControlInteractorTest : SysuiTestCase() { whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true) whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true) val controlModel by collectLastValue(underTest.mediaControl) var mediaData = MediaData(userId = USER_ID, instanceId = instanceId, artist = SESSION_ARTIST) var mediaData = MediaData(userId = USER_ID, instanceId = instanceId, artist = ARTIST) mediaDataFilter.onMediaDataLoaded(KEY, KEY, mediaData) assertThat(controlModel?.instanceId).isEqualTo(instanceId) assertThat(controlModel?.artistName).isEqualTo(SESSION_ARTIST) assertThat(controlModel?.artistName).isEqualTo(ARTIST) mediaData = MediaData(userId = USER_ID, instanceId = instanceId, artist = SESSION_ARTIST_2) mediaData = MediaData(userId = USER_ID, instanceId = instanceId, artist = ARTIST_2) mediaDataFilter.onMediaDataLoaded(KEY, KEY, mediaData) assertThat(controlModel?.instanceId).isEqualTo(instanceId) assertThat(controlModel?.artistName).isEqualTo(SESSION_ARTIST_2) assertThat(controlModel?.artistName).isEqualTo(ARTIST_2) mediaData = MediaData( userId = USER_ID, instanceId = InstanceId.fakeInstanceId(2), artist = SESSION_ARTIST artist = ARTIST ) mediaDataFilter.onMediaDataLoaded(KEY, KEY, mediaData) assertThat(controlModel?.instanceId).isNotEqualTo(mediaData.instanceId) assertThat(controlModel?.artistName).isEqualTo(SESSION_ARTIST_2) assertThat(controlModel?.artistName).isEqualTo(ARTIST_2) } @Test fun startSettings() { underTest.startSettings() verify(activityStarter).startActivity(any(), eq(true)) } @Test fun startClickIntent_showOverLockscreen() { whenever(keyguardStateController.isShowing).thenReturn(true) whenever(kosmos.activityIntentHelper.wouldPendingShowOverLockscreen(any(), any())) .thenReturn(true) val clickIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(true) } val expandable = mock<Expandable>() underTest.startClickIntent(expandable, clickIntent) verify(clickIntent).send(any<Bundle>()) } @Test fun startClickIntent_hideOverLockscreen() { whenever(keyguardStateController.isShowing).thenReturn(false) val clickIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(true) } val expandable = mock<Expandable>() val activityController = mock<ActivityTransitionAnimator.Controller>() whenever(expandable.activityTransitionController(any())).thenReturn(activityController) underTest.startClickIntent(expandable, clickIntent) verify(activityStarter) .postStartActivityDismissingKeyguard(eq(clickIntent), eq(activityController)) } @Test fun startDeviceIntent_showOverLockscreen() { whenever(keyguardStateController.isShowing).thenReturn(true) whenever(kosmos.activityIntentHelper.wouldPendingShowOverLockscreen(any(), any())) .thenReturn(true) val deviceIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(true) } underTest.startDeviceIntent(deviceIntent) verify(deviceIntent).send(any<Bundle>()) } @Test fun startDeviceIntent_intentNotActivity() { whenever(keyguardStateController.isShowing).thenReturn(true) whenever(kosmos.activityIntentHelper.wouldPendingShowOverLockscreen(any(), any())) .thenReturn(true) val deviceIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(false) } underTest.startDeviceIntent(deviceIntent) verify(deviceIntent, never()).send(any<Bundle>()) } @Test fun startDeviceIntent_hideOverLockscreen() { whenever(keyguardStateController.isShowing).thenReturn(false) val deviceIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(true) } underTest.startDeviceIntent(deviceIntent) verify(activityStarter).postStartActivityDismissingKeyguard(eq(deviceIntent)) } @Test fun startMediaOutputDialog() { val expandable = mock<Expandable>() val dialogTransitionController = mock<DialogTransitionAnimator.Controller>() whenever(expandable.dialogTransitionController(any())) .thenReturn(dialogTransitionController) underTest.startMediaOutputDialog(expandable, PACKAGE_NAME) verify(kosmos.mediaOutputDialogManager) .createAndShowWithController(eq(PACKAGE_NAME), eq(true), eq(dialogTransitionController)) } @Test fun startBroadcastDialog() { val expandable = mock<Expandable>() val dialogTransitionController = mock<DialogTransitionAnimator.Controller>() whenever(expandable.dialogTransitionController()).thenReturn(dialogTransitionController) underTest.startBroadcastDialog(expandable, APP_NAME, PACKAGE_NAME) verify(kosmos.mockBroadcastDialogController) .createBroadcastDialogWithController( eq(APP_NAME), eq(PACKAGE_NAME), eq(dialogTransitionController) ) } companion object { private const val USER_ID = 0 private const val KEY = "key" private const val SESSION_ARTIST = "artist" private const val SESSION_ARTIST_2 = "artist2" private const val PACKAGE_NAME = "com.example.app" private const val APP_NAME = "app" private const val ARTIST = "artist" private const val ARTIST_2 = "artist2" } }
packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/ui/viewmodel/MediaControlViewModelTest.kt 0 → 100644 +135 −0 Original line number Diff line number Diff line /* * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.media.controls.ui.viewmodel import android.R import android.content.packageManager import android.content.pm.ApplicationInfo import android.media.MediaMetadata import android.media.session.MediaSession import android.media.session.PlaybackState import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.internal.logging.InstanceId import com.android.systemui.SysuiTestCase import com.android.systemui.coroutines.collectLastValue import com.android.systemui.kosmos.testScope import com.android.systemui.media.controls.domain.pipeline.MediaDataFilterImpl import com.android.systemui.media.controls.domain.pipeline.mediaDataFilter import com.android.systemui.media.controls.shared.model.MediaData import com.android.systemui.media.controls.shared.model.MediaDeviceData import com.android.systemui.media.controls.util.mediaInstanceId import com.android.systemui.statusbar.notificationLockscreenUserManager import com.android.systemui.testKosmos import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.eq import com.android.systemui.util.mockito.whenever import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers import org.mockito.Mockito @SmallTest @RunWith(AndroidJUnit4::class) class MediaControlViewModelTest : SysuiTestCase() { private val kosmos = testKosmos() private val testScope = kosmos.testScope private val mediaDataFilter: MediaDataFilterImpl = kosmos.mediaDataFilter private val notificationLockscreenUserManager = kosmos.notificationLockscreenUserManager private val packageManager = kosmos.packageManager private val drawable = context.getDrawable(R.drawable.ic_media_play) private val instanceId: InstanceId = kosmos.mediaInstanceId private val underTest: MediaControlViewModel = kosmos.mediaControlViewModel @Test fun addMediaControl_mediaControlViewModelIsLoaded() = testScope.runTest { whenever(packageManager.getApplicationIcon(Mockito.anyString())).thenReturn(drawable) whenever(packageManager.getApplicationIcon(any(ApplicationInfo::class.java))) .thenReturn(drawable) whenever(packageManager.getApplicationInfo(eq(PACKAGE_NAME), ArgumentMatchers.anyInt())) .thenReturn(ApplicationInfo()) whenever(packageManager.getApplicationLabel(any())).thenReturn(PACKAGE_NAME) whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true) whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true) val playerModel by collectLastValue(underTest.player) context.setMockPackageManager(packageManager) val mediaData = initMediaData() mediaDataFilter.onMediaDataLoaded(KEY, KEY, mediaData) assertThat(playerModel).isNotNull() assertThat(playerModel?.titleName).isEqualTo(TITLE) assertThat(playerModel?.artistName).isEqualTo(ARTIST) assertThat(playerModel?.gutsMenu).isNotNull() assertThat(playerModel?.outputSwitcher).isNotNull() assertThat(playerModel?.actionButtons).isNotNull() assertThat(playerModel?.playTurbulenceNoise).isFalse() } private fun initMediaData(): MediaData { val device = MediaDeviceData(true, null, DEVICE_NAME, null, showBroadcastButton = true) // Create media session val metadataBuilder = MediaMetadata.Builder().apply { putString(MediaMetadata.METADATA_KEY_ARTIST, SESSION_ARTIST) putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_TITLE) } val playbackBuilder = PlaybackState.Builder().apply { setState(PlaybackState.STATE_PAUSED, 6000L, 1f) setActions(PlaybackState.ACTION_PLAY) } val session = MediaSession(context, SESSION_KEY).apply { setMetadata(metadataBuilder.build()) setPlaybackState(playbackBuilder.build()) } session.isActive = true return MediaData( userId = USER_ID, artist = ARTIST, song = TITLE, packageName = PACKAGE, token = session.sessionToken, device = device, instanceId = instanceId ) } companion object { private const val USER_ID = 0 private const val KEY = "key" private const val PACKAGE_NAME = "com.example.app" private const val PACKAGE = "PKG" private const val ARTIST = "ARTIST" private const val TITLE = "TITLE" private const val DEVICE_NAME = "DEVICE_NAME" private const val SESSION_KEY = "SESSION_KEY" private const val SESSION_ARTIST = "SESSION_ARTIST" private const val SESSION_TITLE = "SESSION_TITLE" } }
packages/SystemUI/src/com/android/systemui/bluetooth/BroadcastDialogController.java +18 −0 Original line number Diff line number Diff line Loading @@ -56,4 +56,22 @@ public class BroadcastDialogController { broadcastDialog.show(); } } /** Creates a [BroadcastDialog] for the user to switch broadcast or change the output device * * @param currentBroadcastAppName Indicates the APP name currently broadcasting * @param outputPkgName Indicates the output media package name to be switched * @param controller Indicates the dialog controller of the source view. */ public void createBroadcastDialogWithController( String currentBroadcastAppName, String outputPkgName, DialogTransitionAnimator.Controller controller) { SystemUIDialog broadcastDialog = mBroadcastDialogFactory.create( currentBroadcastAppName, outputPkgName).createDialog(); if (controller != null) { mDialogTransitionAnimator.show(broadcastDialog, controller); } else { broadcastDialog.show(); } } }
packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt +13 −2 Original line number Diff line number Diff line Loading @@ -590,7 +590,7 @@ class MediaDataProcessor( } /** Dismiss a media entry. Returns false if the key was not found. */ fun dismissMediaData(key: String, delay: Long): Boolean { fun dismissMediaData(key: String, delayMs: Long): Boolean { val existed = mediaDataRepository.mediaEntries.value[key] != null backgroundExecutor.execute { mediaDataRepository.mediaEntries.value[key]?.let { mediaData -> Loading @@ -602,10 +602,21 @@ class MediaDataProcessor( } } } foregroundExecutor.executeDelayed({ removeEntry(key) }, delay) foregroundExecutor.executeDelayed({ removeEntry(key) }, delayMs) return existed } /** Dismiss a media entry. Returns false if the corresponding key was not found. */ fun dismissMediaData(instanceId: InstanceId, delayMs: Long): Boolean { val mediaEntries = mediaDataRepository.mediaEntries.value val filteredEntries = mediaEntries.filter { (_, data) -> data.instanceId == instanceId } return if (filteredEntries.isNotEmpty()) { dismissMediaData(filteredEntries.keys.first(), delayMs) } else { false } } /** * Called whenever the recommendation has been expired or removed by the user. This will remove * the recommendation card entirely from the carousel. Loading
packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaControlInteractor.kt +131 −3 Original line number Diff line number Diff line Loading @@ -16,20 +16,49 @@ package com.android.systemui.media.controls.domain.pipeline.interactor import android.app.ActivityOptions import android.app.BroadcastOptions import android.app.PendingIntent import android.content.Context import android.content.Intent import android.media.session.MediaController import android.media.session.MediaSession import android.media.session.PlaybackState import android.provider.Settings import android.util.Log import com.android.internal.jank.Cuj import com.android.internal.logging.InstanceId import com.android.systemui.ActivityIntentHelper import com.android.systemui.animation.DialogCuj import com.android.systemui.animation.DialogTransitionAnimator import com.android.systemui.animation.Expandable import com.android.systemui.bluetooth.BroadcastDialogController import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.media.controls.data.repository.MediaFilterRepository import com.android.systemui.media.controls.domain.pipeline.MediaDataProcessor import com.android.systemui.media.controls.shared.model.MediaControlModel import com.android.systemui.media.controls.shared.model.MediaData import com.android.systemui.media.dialog.MediaOutputDialogManager import com.android.systemui.plugins.ActivityStarter import com.android.systemui.statusbar.NotificationLockscreenUserManager import com.android.systemui.statusbar.policy.KeyguardStateController import com.android.systemui.util.kotlin.pairwiseBy import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map /** Encapsulates business logic for single media control. */ class MediaControlInteractor( instanceId: InstanceId, @Application applicationContext: Context, private val instanceId: InstanceId, repository: MediaFilterRepository, private val mediaDataProcessor: MediaDataProcessor, private val keyguardStateController: KeyguardStateController, private val activityStarter: ActivityStarter, private val activityIntentHelper: ActivityIntentHelper, private val lockscreenUserManager: NotificationLockscreenUserManager, private val mediaOutputDialogManager: MediaOutputDialogManager, private val broadcastDialogController: BroadcastDialogController, ) { val mediaControl: Flow<MediaControlModel?> = Loading @@ -37,8 +66,32 @@ class MediaControlInteractor( .map { entries -> entries[instanceId]?.let { toMediaControlModel(it) } } .distinctUntilChanged() fun removeMediaControl(key: String, delayMs: Long): Boolean { return mediaDataProcessor.dismissMediaData(key, delayMs) val isStartedPlaying: Flow<Boolean> = mediaControl .map { mediaControl -> mediaControl?.token?.let { token -> MediaController(applicationContext, token).playbackState?.let { it.state == PlaybackState.STATE_PLAYING } } ?: false } .pairwiseBy(initialValue = false) { wasPlaying, isPlaying -> !wasPlaying && isPlaying } .distinctUntilChanged() fun removeMediaControl( token: MediaSession.Token?, instanceId: InstanceId, delayMs: Long ): Boolean { val dismissed = mediaDataProcessor.dismissMediaData(instanceId, delayMs) if (!dismissed) { Log.w( TAG, "Manager failed to dismiss media of instanceId=$instanceId, Token uid=${token?.uid}" ) } return dismissed } private fun toMediaControlModel(data: MediaData): MediaControlModel { Loading @@ -53,14 +106,89 @@ class MediaControlInteractor( appName = app, songName = song, artistName = artist, showExplicit = isExplicit, artwork = artwork, deviceData = device, semanticActionButtons = semanticActions, notificationActionButtons = actions, actionsToShowInCollapsed = actionsToShowInCompact, isDismissible = isClearable, isResume = resumption, resumeProgress = resumeProgress, ) } } fun startSettings() { activityStarter.startActivity(SETTINGS_INTENT, /* dismissShade= */ true) } fun startClickIntent(expandable: Expandable, clickIntent: PendingIntent) { if (!launchOverLockscreen(clickIntent)) { activityStarter.postStartActivityDismissingKeyguard( clickIntent, expandable.activityTransitionController(Cuj.CUJ_SHADE_APP_LAUNCH_FROM_MEDIA_PLAYER) ) } } fun startDeviceIntent(deviceIntent: PendingIntent) { if (deviceIntent.isActivity) { if (!launchOverLockscreen(deviceIntent)) { activityStarter.postStartActivityDismissingKeyguard(deviceIntent) } } else { Log.w(TAG, "Device pending intent of instanceId=$instanceId is not an activity.") } } private fun launchOverLockscreen(pendingIntent: PendingIntent): Boolean { val showOverLockscreen = keyguardStateController.isShowing && activityIntentHelper.wouldPendingShowOverLockscreen( pendingIntent, lockscreenUserManager.currentUserId ) if (showOverLockscreen) { try { val options = BroadcastOptions.makeBasic() options.isInteractive = true options.pendingIntentBackgroundActivityStartMode = ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED pendingIntent.send(options.toBundle()) } catch (e: PendingIntent.CanceledException) { Log.e(TAG, "pending intent of $instanceId was canceled") } return true } return false } fun startMediaOutputDialog(expandable: Expandable, packageName: String) { mediaOutputDialogManager.createAndShowWithController( packageName, true, expandable.dialogController() ) } fun startBroadcastDialog(expandable: Expandable, broadcastApp: String, packageName: String) { broadcastDialogController.createBroadcastDialogWithController( broadcastApp, packageName, expandable.dialogTransitionController() ) } private fun Expandable.dialogController(): DialogTransitionAnimator.Controller? { return dialogTransitionController( cuj = DialogCuj(Cuj.CUJ_SHADE_DIALOG_OPEN, MediaOutputDialogManager.INTERACTION_JANK_TAG) ) } companion object { private const val TAG = "MediaControlInteractor" private val SETTINGS_INTENT = Intent(Settings.ACTION_MEDIA_CONTROLS_SETTINGS) } }