diff options
author | 2024-02-14 19:47:21 +0000 | |
---|---|---|
committer | 2024-02-14 19:47:21 +0000 | |
commit | 45ff6b03c3f58a999b9548eaa7d53db3fc8ac797 (patch) | |
tree | 3b69b28ccd4f80a1699ce501420ab59d3e5f6ee5 | |
parent | 9bc7373cc681bbbf43b47238b38701c553dd7e68 (diff) | |
parent | ef31dd3894d0abbf7cd28044fb7f2463bd83bb28 (diff) |
Merge "Polish domain and repository for use in UI" into main
21 files changed, 709 insertions, 76 deletions
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 000000000000..cda6b8bb36be --- /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 000000000000..1f037c0280e3 --- /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 ab8c6b820177..6925c71fc68f 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 @@ import kotlinx.coroutines.flow.stateIn 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 @@ class MediaControllerRepositoryImpl( 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 430d733e4a88..7bd43d2cf8ab 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 @@ class MediaControllerRepositoryImplTest { ) ) var mediaController: MediaController? = null - underTest.activeMediaController + underTest.activeLocalMediaController .onEach { mediaController = it } .launchIn(backgroundScope) runCurrent() @@ -141,7 +141,7 @@ class MediaControllerRepositoryImplTest { ) ) 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 43d545368536..236aee217f16 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 @@ interface BottomBarModule { @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 index 4dbf865475a4..fe34361540e1 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 @@ import org.junit.runner.RunWith @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 000000000000..ec37925af0f3 --- /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 25d89fac1af5..02be0c1a6c2d 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.settings.UserTracker 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 @@ open class MediaOutputDialogFactory @Inject constructor( 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 ab76d450eb0a..9f99e9778ef2 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.settingslib.volume.shared.AudioManagerIntentsReceiver 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 @@ import kotlinx.coroutines.CoroutineScope @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 0a1ee249d6fb..1f52260bb20d 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 javax.inject.Inject 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 @@ constructor( 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 000000000000..020ec64c0491 --- /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 000000000000..170b32c1d0ea --- /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 6c456f963f03..7126b2379556 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 kotlin.coroutines.CoroutineContext 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 @@ constructor( 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 f250308802b2..71df8e53b5e2 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 @@ sealed interface MediaDeviceSession { 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 000000000000..e1b1966aed6c --- /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 000000000000..3f20df3376d9 --- /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 000000000000..5e1f85c70a1b --- /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 index dddf8e82d5f7..fed3e171862d 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 index 642b72c70e55..7835fc89ea52 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 000000000000..6d52e525d238 --- /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 000000000000..1b3480c423e4 --- /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) +} |