Merge "Polish domain and repository for use in UI" into main
diff --git a/packages/SettingsLib/src/com/android/settingslib/media/session/MediaSessionManagerExt.kt b/packages/SettingsLib/src/com/android/settingslib/media/session/MediaSessionManagerExt.kt
new file mode 100644
index 0000000..cda6b8b
--- /dev/null
+++ b/packages/SettingsLib/src/com/android/settingslib/media/session/MediaSessionManagerExt.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.settingslib.media.session
+
+import android.media.session.MediaController
+import android.media.session.MediaSessionManager
+import android.os.UserHandle
+import androidx.concurrent.futures.DirectExecutor
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.buffer
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.launch
+
+/** [Flow] for [MediaSessionManager.OnActiveSessionsChangedListener]. */
+val MediaSessionManager.activeMediaChanges: Flow<Collection<MediaController>?>
+ get() =
+ callbackFlow {
+ val listener =
+ MediaSessionManager.OnActiveSessionsChangedListener { launch { send(it) } }
+ addOnActiveSessionsChangedListener(
+ null,
+ UserHandle.of(UserHandle.myUserId()),
+ DirectExecutor.INSTANCE,
+ listener,
+ )
+ awaitClose { removeOnActiveSessionsChangedListener(listener) }
+ }
+ .buffer(capacity = Channel.CONFLATED)
diff --git a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerExt.kt b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerExt.kt
new file mode 100644
index 0000000..1f037c0
--- /dev/null
+++ b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerExt.kt
@@ -0,0 +1,100 @@
+/*
+ * 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.settingslib.volume.data.repository
+
+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.os.Handler
+import kotlinx.coroutines.channels.ProducerScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.launch
+
+/** [MediaController.Callback] flow representation. */
+fun MediaController.stateChanges(handler: Handler): Flow<MediaControllerChange> {
+ return callbackFlow {
+ val callback = MediaControllerCallbackProducer(this)
+ registerCallback(callback, handler)
+ awaitClose { unregisterCallback(callback) }
+ }
+}
+
+/** Models particular change event received by [MediaController.Callback]. */
+sealed interface MediaControllerChange {
+
+ data object SessionDestroyed : MediaControllerChange
+
+ data class SessionEvent(val event: String, val extras: Bundle?) : MediaControllerChange
+
+ data class PlaybackStateChanged(val state: PlaybackState?) : MediaControllerChange
+
+ data class MetadataChanged(val metadata: MediaMetadata?) : MediaControllerChange
+
+ data class QueueChanged(val queue: MutableList<MediaSession.QueueItem>?) :
+ MediaControllerChange
+
+ data class QueueTitleChanged(val title: CharSequence?) : MediaControllerChange
+
+ data class ExtrasChanged(val extras: Bundle?) : MediaControllerChange
+
+ data class AudioInfoChanged(val info: MediaController.PlaybackInfo?) : MediaControllerChange
+}
+
+private class MediaControllerCallbackProducer(
+ private val producingScope: ProducerScope<MediaControllerChange>
+) : MediaController.Callback() {
+
+ override fun onSessionDestroyed() {
+ send(MediaControllerChange.SessionDestroyed)
+ }
+
+ override fun onSessionEvent(event: String, extras: Bundle?) {
+ send(MediaControllerChange.SessionEvent(event, extras))
+ }
+
+ override fun onPlaybackStateChanged(state: PlaybackState?) {
+ send(MediaControllerChange.PlaybackStateChanged(state))
+ }
+
+ override fun onMetadataChanged(metadata: MediaMetadata?) {
+ send(MediaControllerChange.MetadataChanged(metadata))
+ }
+
+ override fun onQueueChanged(queue: MutableList<MediaSession.QueueItem>?) {
+ send(MediaControllerChange.QueueChanged(queue))
+ }
+
+ override fun onQueueTitleChanged(title: CharSequence?) {
+ send(MediaControllerChange.QueueTitleChanged(title))
+ }
+
+ override fun onExtrasChanged(extras: Bundle?) {
+ send(MediaControllerChange.ExtrasChanged(extras))
+ }
+
+ override fun onAudioInfoChanged(info: MediaController.PlaybackInfo?) {
+ send(MediaControllerChange.AudioInfoChanged(info))
+ }
+
+ private fun send(change: MediaControllerChange) {
+ producingScope.launch { producingScope.send(change) }
+ }
+}
diff --git a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerRepository.kt b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerRepository.kt
index ab8c6b8..6925c71 100644
--- a/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerRepository.kt
+++ b/packages/SettingsLib/src/com/android/settingslib/volume/data/repository/MediaControllerRepository.kt
@@ -16,21 +16,23 @@
package com.android.settingslib.volume.data.repository
+import android.content.Intent
import android.media.AudioManager
import android.media.session.MediaController
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import com.android.settingslib.bluetooth.LocalBluetoothManager
import com.android.settingslib.bluetooth.headsetAudioModeChanges
+import com.android.settingslib.media.session.activeMediaChanges
import com.android.settingslib.volume.shared.AudioManagerIntentsReceiver
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
@@ -38,7 +40,7 @@
interface MediaControllerRepository {
/** Current [MediaController]. Null is emitted when there is no active [MediaController]. */
- val activeMediaController: StateFlow<MediaController?>
+ val activeLocalMediaController: StateFlow<MediaController?>
}
class MediaControllerRepositoryImpl(
@@ -53,26 +55,28 @@
audioManagerIntentsReceiver.intents.filter {
AudioManager.STREAM_DEVICES_CHANGED_ACTION == it.action
}
- override val activeMediaController: StateFlow<MediaController?> =
- buildList {
- localBluetoothManager?.headsetAudioModeChanges?.let { add(it) }
- add(devicesChanges)
+
+ override val activeLocalMediaController: StateFlow<MediaController?> =
+ combine(
+ mediaSessionManager.activeMediaChanges.onStart {
+ emit(mediaSessionManager.getActiveSessions(null))
+ },
+ localBluetoothManager?.headsetAudioModeChanges?.onStart { emit(Unit) }
+ ?: flowOf(null),
+ devicesChanges.onStart { emit(Intent()) },
+ ) { controllers, _, _ ->
+ controllers?.let(::findLocalMediaController)
}
- .merge()
- .onStart { emit(Unit) }
- .map { getActiveLocalMediaController() }
.flowOn(backgroundContext)
.stateIn(coroutineScope, SharingStarted.WhileSubscribed(), null)
- private fun getActiveLocalMediaController(): MediaController? {
+ private fun findLocalMediaController(
+ controllers: Collection<MediaController>,
+ ): MediaController? {
var localController: MediaController? = null
val remoteMediaSessionLists: MutableList<String> = ArrayList()
- for (controller in mediaSessionManager.getActiveSessions(null)) {
+ for (controller in controllers) {
val playbackInfo: MediaController.PlaybackInfo = controller.playbackInfo ?: continue
- val playbackState = controller.playbackState ?: continue
- if (inactivePlaybackStates.contains(playbackState.state)) {
- continue
- }
when (playbackInfo.playbackType) {
MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE -> {
if (localController?.packageName.equals(controller.packageName)) {
diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/MediaControllerRepositoryImplTest.kt b/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/MediaControllerRepositoryImplTest.kt
index 430d733..7bd43d2 100644
--- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/MediaControllerRepositoryImplTest.kt
+++ b/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/MediaControllerRepositoryImplTest.kt
@@ -116,7 +116,7 @@
)
)
var mediaController: MediaController? = null
- underTest.activeMediaController
+ underTest.activeLocalMediaController
.onEach { mediaController = it }
.launchIn(backgroundScope)
runCurrent()
@@ -141,7 +141,7 @@
)
)
var mediaController: MediaController? = null
- underTest.activeMediaController
+ underTest.activeLocalMediaController
.onEach { mediaController = it }
.launchIn(backgroundScope)
runCurrent()
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/bottombar/BottomBarModule.kt b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/bottombar/BottomBarModule.kt
index 43d5453..236aee2 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/bottombar/BottomBarModule.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/bottombar/BottomBarModule.kt
@@ -32,7 +32,7 @@
@Binds
@IntoMap
@StringKey(VolumePanelComponents.BOTTOM_BAR)
- fun bindMediaVolumeSliderComponent(component: BottomBarComponent): VolumePanelUiComponent
+ fun bindVolumePanelUiComponent(component: BottomBarComponent): VolumePanelUiComponent
@Binds
@IntoMap
diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/domain/interactor/AudioModeInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/domain/interactor/AudioModeInteractorTest.kt
similarity index 88%
rename from packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/domain/interactor/AudioModeInteractorTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/volume/domain/interactor/AudioModeInteractorTest.kt
index 4dbf865..fe34361 100644
--- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/domain/interactor/AudioModeInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/domain/interactor/AudioModeInteractorTest.kt
@@ -14,13 +14,14 @@
* limitations under the License.
*/
-package com.android.settingslib.volume.domain.interactor
+package com.android.systemui.volume.domain.interactor
import android.media.AudioManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
-import com.android.settingslib.BaseTest
-import com.android.settingslib.volume.data.repository.FakeAudioRepository
+import com.android.settingslib.volume.domain.interactor.AudioModeInteractor
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.volume.data.repository.FakeAudioRepository
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
@@ -34,7 +35,7 @@
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
@SmallTest
-class AudioModeInteractorTest : BaseTest() {
+class AudioModeInteractorTest : SysuiTestCase() {
private val testScope = TestScope()
private val fakeAudioRepository = FakeAudioRepository()
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/domain/MediaOutputAvailabilityCriteriaTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/domain/MediaOutputAvailabilityCriteriaTest.kt
new file mode 100644
index 0000000..ec37925
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/panel/component/mediaoutput/domain/MediaOutputAvailabilityCriteriaTest.kt
@@ -0,0 +1,111 @@
+/*
+ * 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.volume.panel.component.mediaoutput.domain
+
+import android.media.AudioManager
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.testing.TestableLooper.RunWithLooper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.testKosmos
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.mockito.whenever
+import com.android.systemui.volume.audioModeInteractor
+import com.android.systemui.volume.audioRepository
+import com.android.systemui.volume.localMediaRepository
+import com.android.systemui.volume.mediaController
+import com.android.systemui.volume.mediaControllerRepository
+import com.android.systemui.volume.mediaOutputInteractor
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+@RunWithLooper(setAsMainLooper = true)
+class MediaOutputAvailabilityCriteriaTest : SysuiTestCase() {
+
+ private val kosmos = testKosmos()
+
+ private lateinit var underTest: MediaOutputAvailabilityCriteria
+
+ @Before
+ fun setup() {
+ with(kosmos) {
+ whenever(mediaController.packageName).thenReturn("test.pkg")
+ whenever(mediaController.sessionToken).thenReturn(MediaSession.Token(0, mock {}))
+ whenever(mediaController.playbackState).thenReturn(PlaybackState.Builder().build())
+
+ mediaControllerRepository.setActiveLocalMediaController(mediaController)
+
+ underTest = MediaOutputAvailabilityCriteria(mediaOutputInteractor, audioModeInteractor)
+ }
+ }
+
+ @Test
+ fun notInCallAndHasDevices_isAvailable_true() {
+ with(kosmos) {
+ testScope.runTest {
+ audioRepository.setMode(AudioManager.MODE_NORMAL)
+ localMediaRepository.updateMediaDevices(listOf(mock {}))
+
+ val isAvailable by collectLastValue(underTest.isAvailable())
+ runCurrent()
+
+ assertThat(isAvailable).isTrue()
+ }
+ }
+ }
+ @Test
+ fun inCallAndHasDevices_isAvailable_false() {
+ with(kosmos) {
+ testScope.runTest {
+ audioRepository.setMode(AudioManager.MODE_IN_CALL)
+ localMediaRepository.updateMediaDevices(listOf(mock {}))
+
+ val isAvailable by collectLastValue(underTest.isAvailable())
+ runCurrent()
+
+ assertThat(isAvailable).isFalse()
+ }
+ }
+ }
+
+ @Test
+ fun notInCallAndHasDevices_isAvailable_false() {
+ with(kosmos) {
+ testScope.runTest {
+ audioRepository.setMode(AudioManager.MODE_NORMAL)
+ localMediaRepository.updateMediaDevices(emptyList())
+
+ val isAvailable by collectLastValue(underTest.isAvailable())
+ runCurrent()
+
+ assertThat(isAvailable).isFalse()
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialogFactory.kt b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialogFactory.kt
index 25d89fa..02be0c1 100644
--- a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialogFactory.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialogFactory.kt
@@ -35,10 +35,10 @@
import com.android.systemui.statusbar.notification.collection.notifcollection.CommonNotifCollection
import javax.inject.Inject
-/**
- * Factory to create [MediaOutputDialog] objects.
- */
-open class MediaOutputDialogFactory @Inject constructor(
+/** Factory to create [MediaOutputDialog] objects. */
+open class MediaOutputDialogFactory
+@Inject
+constructor(
private val context: Context,
private val mediaSessionManager: MediaSessionManager,
private val lbm: LocalBluetoothManager?,
@@ -55,46 +55,93 @@
private val userTracker: UserTracker
) {
companion object {
- private const val INTERACTION_JANK_TAG = "media_output"
+ const val INTERACTION_JANK_TAG = "media_output"
var mediaOutputDialog: MediaOutputDialog? = null
}
/** Creates a [MediaOutputDialog] for the given package. */
open fun create(packageName: String, aboveStatusBar: Boolean, view: View? = null) {
- create(packageName, aboveStatusBar, view, includePlaybackAndAppMetadata = true)
+ createWithController(
+ packageName,
+ aboveStatusBar,
+ controller =
+ view?.let {
+ DialogTransitionAnimator.Controller.fromView(
+ it,
+ DialogCuj(
+ InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
+ INTERACTION_JANK_TAG
+ )
+ )
+ },
+ )
}
- open fun createDialogForSystemRouting() {
- create(packageName = null, aboveStatusBar = false, includePlaybackAndAppMetadata = false)
+ /** Creates a [MediaOutputDialog] for the given package. */
+ open fun createWithController(
+ packageName: String,
+ aboveStatusBar: Boolean,
+ controller: DialogTransitionAnimator.Controller?,
+ ) {
+ create(
+ packageName,
+ aboveStatusBar,
+ dialogTransitionAnimatorController = controller,
+ includePlaybackAndAppMetadata = true
+ )
+ }
+
+ open fun createDialogForSystemRouting(controller: DialogTransitionAnimator.Controller? = null) {
+ create(
+ packageName = null,
+ aboveStatusBar = false,
+ dialogTransitionAnimatorController = null,
+ includePlaybackAndAppMetadata = false
+ )
}
private fun create(
- packageName: String?,
- aboveStatusBar: Boolean,
- view: View? = null,
- includePlaybackAndAppMetadata: Boolean = true
+ packageName: String?,
+ aboveStatusBar: Boolean,
+ dialogTransitionAnimatorController: DialogTransitionAnimator.Controller?,
+ includePlaybackAndAppMetadata: Boolean = true
) {
// Dismiss the previous dialog, if any.
mediaOutputDialog?.dismiss()
- val controller = MediaOutputController(
- context, packageName,
- mediaSessionManager, lbm, starter, notifCollection,
- dialogTransitionAnimator, nearbyMediaDevicesManager, audioManager,
- powerExemptionManager, keyGuardManager, featureFlags, userTracker)
+ val controller =
+ MediaOutputController(
+ context,
+ packageName,
+ mediaSessionManager,
+ lbm,
+ starter,
+ notifCollection,
+ dialogTransitionAnimator,
+ nearbyMediaDevicesManager,
+ audioManager,
+ powerExemptionManager,
+ keyGuardManager,
+ featureFlags,
+ userTracker
+ )
val dialog =
- MediaOutputDialog(context, aboveStatusBar, broadcastSender, controller,
- dialogTransitionAnimator, uiEventLogger, includePlaybackAndAppMetadata)
+ MediaOutputDialog(
+ context,
+ aboveStatusBar,
+ broadcastSender,
+ controller,
+ dialogTransitionAnimator,
+ uiEventLogger,
+ includePlaybackAndAppMetadata
+ )
mediaOutputDialog = dialog
// Show the dialog.
- if (view != null) {
- dialogTransitionAnimator.showFromView(
- dialog, view,
- cuj = DialogCuj(
- InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
- INTERACTION_JANK_TAG
- )
+ if (dialogTransitionAnimatorController != null) {
+ dialogTransitionAnimator.show(
+ dialog,
+ dialogTransitionAnimatorController,
)
} else {
dialog.show()
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dagger/MediaDevicesModule.kt b/packages/SystemUI/src/com/android/systemui/volume/dagger/MediaDevicesModule.kt
index ab76d45..9f99e97 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dagger/MediaDevicesModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dagger/MediaDevicesModule.kt
@@ -24,6 +24,9 @@
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.volume.panel.component.mediaoutput.data.repository.LocalMediaRepositoryFactory
+import com.android.systemui.volume.panel.component.mediaoutput.data.repository.LocalMediaRepositoryFactoryImpl
+import dagger.Binds
import dagger.Module
import dagger.Provides
import kotlin.coroutines.CoroutineContext
@@ -32,6 +35,11 @@
@Module
interface MediaDevicesModule {
+ @Binds
+ fun bindLocalMediaRepositoryFactory(
+ impl: LocalMediaRepositoryFactoryImpl
+ ): LocalMediaRepositoryFactory
+
companion object {
@Provides
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/LocalMediaRepositoryFactory.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/LocalMediaRepositoryFactory.kt
index 0a1ee24..1f52260 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/LocalMediaRepositoryFactory.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/LocalMediaRepositoryFactory.kt
@@ -26,7 +26,12 @@
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
-class LocalMediaRepositoryFactory
+interface LocalMediaRepositoryFactory {
+
+ fun create(packageName: String?): LocalMediaRepository
+}
+
+class LocalMediaRepositoryFactoryImpl
@Inject
constructor(
private val intentsReceiver: AudioManagerIntentsReceiver,
@@ -34,9 +39,9 @@
private val localMediaManagerFactory: LocalMediaManagerFactory,
@Application private val coroutineScope: CoroutineScope,
@Background private val backgroundCoroutineContext: CoroutineContext,
-) {
+) : LocalMediaRepositoryFactory {
- fun create(packageName: String?): LocalMediaRepository =
+ override fun create(packageName: String?): LocalMediaRepository =
LocalMediaRepositoryImpl(
intentsReceiver,
localMediaManagerFactory.create(packageName),
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/MediaOutputAvailabilityCriteria.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/MediaOutputAvailabilityCriteria.kt
new file mode 100644
index 0000000..020ec64
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/MediaOutputAvailabilityCriteria.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.volume.panel.component.mediaoutput.domain
+
+import com.android.settingslib.volume.domain.interactor.AudioModeInteractor
+import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputInteractor
+import com.android.systemui.volume.panel.dagger.scope.VolumePanelScope
+import com.android.systemui.volume.panel.domain.ComponentAvailabilityCriteria
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+
+/** Determines if the Media Output Volume Panel component is available. */
+@VolumePanelScope
+class MediaOutputAvailabilityCriteria
+@Inject
+constructor(
+ private val mediaOutputInteractor: MediaOutputInteractor,
+ private val audioModeInteractor: AudioModeInteractor,
+) : ComponentAvailabilityCriteria {
+
+ override fun isAvailable(): Flow<Boolean> {
+ return combine(mediaOutputInteractor.mediaDevices, audioModeInteractor.isOngoingCall) {
+ devices,
+ isOngoingCall ->
+ !isOngoingCall && devices.isNotEmpty()
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputActionsInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputActionsInteractor.kt
new file mode 100644
index 0000000..170b32c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputActionsInteractor.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.volume.panel.component.mediaoutput.domain.interactor
+
+import android.content.Intent
+import android.provider.Settings
+import com.android.internal.jank.InteractionJankMonitor
+import com.android.systemui.animation.DialogCuj
+import com.android.systemui.animation.DialogTransitionAnimator
+import com.android.systemui.animation.Expandable
+import com.android.systemui.media.dialog.MediaOutputDialogFactory
+import com.android.systemui.plugins.ActivityStarter
+import com.android.systemui.volume.panel.component.mediaoutput.domain.model.MediaDeviceSession
+import com.android.systemui.volume.panel.dagger.scope.VolumePanelScope
+import javax.inject.Inject
+
+/** User actions interactor for Media Output Volume Panel component. */
+@VolumePanelScope
+class MediaOutputActionsInteractor
+@Inject
+constructor(
+ private val mediaOutputDialogFactory: MediaOutputDialogFactory,
+ private val activityStarter: ActivityStarter,
+) {
+
+ fun onDeviceClick(expandable: Expandable) {
+ activityStarter.startActivity(
+ Intent(Settings.ACTION_BLUETOOTH_SETTINGS),
+ true,
+ expandable.activityTransitionController(),
+ )
+ }
+
+ fun onBarClick(session: MediaDeviceSession, expandable: Expandable) {
+ when (session) {
+ is MediaDeviceSession.Active -> {
+ mediaOutputDialogFactory.createWithController(
+ session.packageName,
+ false,
+ expandable.dialogController()
+ )
+ }
+ is MediaDeviceSession.Inactive -> {
+ mediaOutputDialogFactory.createDialogForSystemRouting(expandable.dialogController())
+ }
+ else -> {
+ /* do nothing */
+ }
+ }
+ }
+
+ private fun Expandable.dialogController(): DialogTransitionAnimator.Controller? {
+ return dialogTransitionController(
+ cuj =
+ DialogCuj(
+ InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
+ MediaOutputDialogFactory.INTERACTION_JANK_TAG
+ )
+ )
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputInteractor.kt
index 6c456f9..7126b23 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/interactor/MediaOutputInteractor.kt
@@ -17,10 +17,14 @@
package com.android.systemui.volume.panel.component.mediaoutput.domain.interactor
import android.content.pm.PackageManager
+import android.media.session.MediaController
+import android.os.Handler
import android.util.Log
import com.android.settingslib.media.MediaDevice
import com.android.settingslib.volume.data.repository.LocalMediaRepository
+import com.android.settingslib.volume.data.repository.MediaControllerChange
import com.android.settingslib.volume.data.repository.MediaControllerRepository
+import com.android.settingslib.volume.data.repository.stateChanges
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.volume.panel.component.mediaoutput.data.repository.LocalMediaRepositoryFactory
import com.android.systemui.volume.panel.component.mediaoutput.domain.model.MediaDeviceSession
@@ -30,14 +34,20 @@
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
+/** Provides observable models about the current media session state. */
@OptIn(ExperimentalCoroutinesApi::class)
@VolumePanelScope
class MediaOutputInteractor
@@ -47,32 +57,43 @@
private val packageManager: PackageManager,
@VolumePanelScope private val coroutineScope: CoroutineScope,
@Background private val backgroundCoroutineContext: CoroutineContext,
+ @Background private val backgroundHandler: Handler,
mediaControllerRepository: MediaControllerRepository
) {
- val mediaDeviceSession: Flow<MediaDeviceSession> =
- mediaControllerRepository.activeMediaController.mapNotNull { mediaController ->
- if (mediaController == null) {
- MediaDeviceSession.Inactive
- } else {
+ /** Current [MediaDeviceSession]. Emits when the session playback changes. */
+ val mediaDeviceSession: StateFlow<MediaDeviceSession> =
+ mediaControllerRepository.activeLocalMediaController
+ .flatMapLatest { it?.mediaDeviceSession() ?: flowOf(MediaDeviceSession.Inactive) }
+ .flowOn(backgroundCoroutineContext)
+ .stateIn(coroutineScope, SharingStarted.Eagerly, MediaDeviceSession.Inactive)
+
+ private fun MediaController.mediaDeviceSession(): Flow<MediaDeviceSession> {
+ return stateChanges(backgroundHandler)
+ .filter { it is MediaControllerChange.PlaybackStateChanged }
+ .map {
MediaDeviceSession.Active(
- appLabel = getApplicationLabel(mediaController.packageName)
- ?: return@mapNotNull null,
- packageName = mediaController.packageName,
- sessionToken = mediaController.sessionToken,
+ appLabel = getApplicationLabel(packageName)
+ ?: return@map MediaDeviceSession.Inactive,
+ packageName = packageName,
+ sessionToken = sessionToken,
+ playbackState = playbackState,
)
}
- }
- private val localMediaRepository: Flow<LocalMediaRepository> =
+ }
+
+ private val localMediaRepository: SharedFlow<LocalMediaRepository> =
mediaDeviceSession
.map { (it as? MediaDeviceSession.Active)?.packageName }
.distinctUntilChanged()
.map { localMediaRepositoryFactory.create(it) }
- .shareIn(coroutineScope, SharingStarted.WhileSubscribed(), replay = 1)
+ .shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1)
+ /** Currently connected [MediaDevice]. */
val currentConnectedDevice: Flow<MediaDevice?> =
localMediaRepository.flatMapLatest { it.currentConnectedDevice }
+ /** A list of available [MediaDevice]s. */
val mediaDevices: Flow<Collection<MediaDevice>> =
localMediaRepository.flatMapLatest { it.mediaDevices }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSession.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSession.kt
index f250308..71df8e5 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSession.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/mediaoutput/domain/model/MediaDeviceSession.kt
@@ -17,6 +17,7 @@
package com.android.systemui.volume.panel.component.mediaoutput.domain.model
import android.media.session.MediaSession
+import android.media.session.PlaybackState
/** Represents media playing on the connected device. */
sealed interface MediaDeviceSession {
@@ -26,6 +27,7 @@
val appLabel: CharSequence,
val packageName: String,
val sessionToken: MediaSession.Token,
+ val playbackState: PlaybackState?,
) : MediaDeviceSession
/** Media is not playing. */
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/MediaKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/MediaKosmos.kt
new file mode 100644
index 0000000..e1b1966
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/MediaKosmos.kt
@@ -0,0 +1,23 @@
+/*
+ * 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
+
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.media.dialog.MediaOutputDialogFactory
+import com.android.systemui.util.mockito.mock
+
+var Kosmos.mediaOutputDialogFactory: MediaOutputDialogFactory by Kosmos.Fixture { mock {} }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaOutputKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaOutputKosmos.kt
new file mode 100644
index 0000000..3f20df3
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/MediaOutputKosmos.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.volume
+
+import android.content.packageManager
+import android.content.pm.ApplicationInfo
+import android.media.session.MediaController
+import android.os.Handler
+import android.testing.TestableLooper
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.testCase
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.media.mediaOutputDialogFactory
+import com.android.systemui.plugins.activityStarter
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.mockito.whenever
+import com.android.systemui.volume.data.repository.FakeLocalMediaRepository
+import com.android.systemui.volume.data.repository.FakeMediaControllerRepository
+import com.android.systemui.volume.panel.component.mediaoutput.data.repository.FakeLocalMediaRepositoryFactory
+import com.android.systemui.volume.panel.component.mediaoutput.data.repository.LocalMediaRepositoryFactory
+import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputActionsInteractor
+import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputInteractor
+
+var Kosmos.mediaController: MediaController by Kosmos.Fixture { mock {} }
+
+val Kosmos.localMediaRepository by Kosmos.Fixture { FakeLocalMediaRepository() }
+val Kosmos.localMediaRepositoryFactory: LocalMediaRepositoryFactory by
+ Kosmos.Fixture { FakeLocalMediaRepositoryFactory { localMediaRepository } }
+
+val Kosmos.mediaOutputActionsInteractor by
+ Kosmos.Fixture { MediaOutputActionsInteractor(mediaOutputDialogFactory, activityStarter) }
+val Kosmos.mediaControllerRepository by Kosmos.Fixture { FakeMediaControllerRepository() }
+val Kosmos.mediaOutputInteractor by
+ Kosmos.Fixture {
+ MediaOutputInteractor(
+ localMediaRepositoryFactory,
+ packageManager.apply {
+ val appInfo: ApplicationInfo = mock {
+ whenever(loadLabel(any())).thenReturn("test_label")
+ }
+ whenever(getApplicationInfo(any(), any<Int>())).thenReturn(appInfo)
+ },
+ testScope.backgroundScope,
+ testScope.testScheduler,
+ Handler(TestableLooper.get(testCase).looper),
+ mediaControllerRepository,
+ )
+ }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/VolumeKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/VolumeKosmos.kt
new file mode 100644
index 0000000..5e1f85c
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/VolumeKosmos.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.volume
+
+import com.android.settingslib.volume.domain.interactor.AudioModeInteractor
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.volume.data.repository.FakeAudioRepository
+
+val Kosmos.audioRepository by Kosmos.Fixture { FakeAudioRepository() }
+val Kosmos.audioModeInteractor by Kosmos.Fixture { AudioModeInteractor(audioRepository) }
diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/FakeAudioRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeAudioRepository.kt
similarity index 96%
rename from packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/FakeAudioRepository.kt
rename to packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeAudioRepository.kt
index dddf8e82..fed3e17 100644
--- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/FakeAudioRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeAudioRepository.kt
@@ -14,9 +14,10 @@
* limitations under the License.
*/
-package com.android.settingslib.volume.data.repository
+package com.android.systemui.volume.data.repository
import android.media.AudioDeviceInfo
+import com.android.settingslib.volume.data.repository.AudioRepository
import com.android.settingslib.volume.shared.model.AudioStream
import com.android.settingslib.volume.shared.model.AudioStreamModel
import com.android.settingslib.volume.shared.model.RingerMode
diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/FakeLocalMediaRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeLocalMediaRepository.kt
similarity index 71%
rename from packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/FakeLocalMediaRepository.kt
rename to packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeLocalMediaRepository.kt
index 642b72c..7835fc8 100644
--- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/volume/data/repository/FakeLocalMediaRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeLocalMediaRepository.kt
@@ -1,23 +1,24 @@
/*
* 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
+ * 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
+ * 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.
+ * 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.settingslib.volume.data.repository
+package com.android.systemui.volume.data.repository
import com.android.settingslib.media.MediaDevice
import com.android.settingslib.volume.data.model.RoutingSession
+import com.android.settingslib.volume.data.repository.LocalMediaRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeMediaControllerRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeMediaControllerRepository.kt
new file mode 100644
index 0000000..6d52e52
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/data/repository/FakeMediaControllerRepository.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.volume.data.repository
+
+import android.media.session.MediaController
+import com.android.settingslib.volume.data.repository.MediaControllerRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class FakeMediaControllerRepository : MediaControllerRepository {
+
+ private val mutableActiveLocalMediaController = MutableStateFlow<MediaController?>(null)
+ override val activeLocalMediaController: StateFlow<MediaController?> =
+ mutableActiveLocalMediaController.asStateFlow()
+
+ fun setActiveLocalMediaController(controller: MediaController?) {
+ mutableActiveLocalMediaController.value = controller
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/FakeLocalMediaRepositoryFactory.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/FakeLocalMediaRepositoryFactory.kt
new file mode 100644
index 0000000..1b3480c
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/panel/component/mediaoutput/data/repository/FakeLocalMediaRepositoryFactory.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.volume.panel.component.mediaoutput.data.repository
+
+import com.android.settingslib.volume.data.repository.LocalMediaRepository
+
+class FakeLocalMediaRepositoryFactory(
+ val provider: (packageName: String?) -> LocalMediaRepository
+) : LocalMediaRepositoryFactory {
+
+ override fun create(packageName: String?): LocalMediaRepository = provider(packageName)
+}