Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 92f96ae7 authored by Haijie Hong's avatar Haijie Hong
Browse files

Update media output bar behavior when audio sharing

1. The media output bar will be unclickable.
2. Device name will become "Sharing Audio".

Flag: com.android.settingslib.flags.enable_le_audio_sharing
Test: manual on phone when audio sharing
Bug: 336183611
Change-Id: Ifed6a143b888924e5747a0462db04a7e6d277d09
parent 9c0687ee
Loading
Loading
Loading
Loading
+100 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.settingslib.volume.data.repository

import android.bluetooth.BluetoothLeBroadcast
import android.bluetooth.BluetoothLeBroadcastMetadata
import com.android.internal.util.ConcurrentUtils
import com.android.settingslib.bluetooth.LocalBluetoothManager
import com.android.settingslib.flags.Flags
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch

/** Provides audio sharing functionality. */
interface AudioSharingRepository {
    /** Whether the device is in audio sharing. */
    val inAudioSharing: Flow<Boolean>
}

class AudioSharingRepositoryImpl(
    private val localBluetoothManager: LocalBluetoothManager?,
    backgroundCoroutineContext: CoroutineContext,
) : AudioSharingRepository {
    override val inAudioSharing: Flow<Boolean> =
        if (Flags.enableLeAudioSharing()) {
            localBluetoothManager?.profileManager?.leAudioBroadcastProfile?.let { leBroadcast ->
                callbackFlow {
                        val listener =
                            object : BluetoothLeBroadcast.Callback {
                                override fun onBroadcastStarted(reason: Int, broadcastId: Int) {
                                    launch { send(isBroadcasting()) }
                                }

                                override fun onBroadcastStartFailed(reason: Int) {
                                    launch { send(isBroadcasting()) }
                                }

                                override fun onBroadcastStopped(reason: Int, broadcastId: Int) {
                                    launch { send(isBroadcasting()) }
                                }

                                override fun onBroadcastStopFailed(reason: Int) {
                                    launch { send(isBroadcasting()) }
                                }

                                override fun onPlaybackStarted(reason: Int, broadcastId: Int) {}

                                override fun onPlaybackStopped(reason: Int, broadcastId: Int) {}

                                override fun onBroadcastUpdated(reason: Int, broadcastId: Int) {}

                                override fun onBroadcastUpdateFailed(
                                    reason: Int,
                                    broadcastId: Int
                                ) {}

                                override fun onBroadcastMetadataChanged(
                                    broadcastId: Int,
                                    metadata: BluetoothLeBroadcastMetadata
                                ) {}
                            }

                        leBroadcast.registerServiceCallBack(
                            ConcurrentUtils.DIRECT_EXECUTOR,
                            listener,
                        )
                        awaitClose { leBroadcast.unregisterServiceCallBack(listener) }
                    }
                    .onStart { emit(isBroadcasting()) }
                    .flowOn(backgroundCoroutineContext)
            } ?: flowOf(false)
        } else {
            flowOf(false)
        }

    private fun isBroadcasting(): Boolean {
        return Flags.enableLeAudioSharing() &&
            (localBluetoothManager?.profileManager?.leAudioBroadcastProfile?.isEnabled(null)
                ?: false)
    }
}
+120 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.settingslib.volume.data.repository

import android.bluetooth.BluetoothLeBroadcast
import android.platform.test.annotations.DisableFlags
import android.platform.test.annotations.EnableFlags
import android.platform.test.flag.junit.SetFlagsRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast
import com.android.settingslib.bluetooth.LocalBluetoothManager
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager
import com.android.settingslib.flags.Flags
import com.google.common.truth.Truth
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any
import org.mockito.Captor
import org.mockito.Mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
class AudioSharingRepositoryTest {
    @get:Rule val mockito: MockitoRule = MockitoJUnit.rule()
    @get:Rule val setFlagsRule: SetFlagsRule = SetFlagsRule()

    @Mock private lateinit var localBluetoothManager: LocalBluetoothManager
    @Mock private lateinit var localBluetoothProfileManager: LocalBluetoothProfileManager
    @Mock private lateinit var localBluetoothLeBroadcast: LocalBluetoothLeBroadcast

    @Captor
    private lateinit var leBroadcastCallbackCaptor: ArgumentCaptor<BluetoothLeBroadcast.Callback>
    private val testScope = TestScope()

    private lateinit var underTest: AudioSharingRepository

    @Before
    fun setup() {
        `when`(localBluetoothManager.profileManager).thenReturn(localBluetoothProfileManager)
        `when`(localBluetoothProfileManager.leAudioBroadcastProfile)
            .thenReturn(localBluetoothLeBroadcast)
        `when`(localBluetoothLeBroadcast.isEnabled(null)).thenReturn(true)
        underTest =
            AudioSharingRepositoryImpl(
                localBluetoothManager,
                testScope.testScheduler,
            )
    }

    @Test
    @EnableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING)
    fun audioSharingStateChange_emitValues() {
        testScope.runTest {
            val states = mutableListOf<Boolean?>()
            underTest.inAudioSharing.onEach { states.add(it) }.launchIn(backgroundScope)
            runCurrent()
            triggerAudioSharingStateChange(false)
            runCurrent()
            triggerAudioSharingStateChange(true)
            runCurrent()

            Truth.assertThat(states).containsExactly(true, false, true)
        }
    }

    @Test
    @DisableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING)
    fun audioSharingFlagOff_returnFalse() {
        testScope.runTest {
            val states = mutableListOf<Boolean?>()
            underTest.inAudioSharing.onEach { states.add(it) }.launchIn(backgroundScope)
            runCurrent()

            Truth.assertThat(states).containsExactly(false)
            verify(localBluetoothLeBroadcast, never()).registerServiceCallBack(any(), any())
            verify(localBluetoothLeBroadcast, never()).isEnabled(any())
        }
    }

    private fun triggerAudioSharingStateChange(inAudioSharing: Boolean) {
        verify(localBluetoothLeBroadcast)
            .registerServiceCallBack(any(), leBroadcastCallbackCaptor.capture())
        `when`(localBluetoothLeBroadcast.isEnabled(null)).thenReturn(inAudioSharing)
        if (inAudioSharing) {
            leBroadcastCallbackCaptor.value.onBroadcastStarted(0, 0)
        } else {
            leBroadcastCallbackCaptor.value.onBroadcastStopped(0, 0)
        }
    }
}
+7 −1
Original line number Diff line number Diff line
@@ -81,6 +81,7 @@ constructor(
        val deviceIconViewModel: DeviceIconViewModel? by
            viewModel.deviceIconViewModel.collectAsStateWithLifecycle()
        val clickLabel = stringResource(R.string.volume_panel_enter_media_output_settings)
        val enabled: Boolean by viewModel.enabled.collectAsStateWithLifecycle()

        Expandable(
            modifier =
@@ -93,7 +94,12 @@ constructor(
                },
            color = MaterialTheme.colorScheme.surface,
            shape = RoundedCornerShape(28.dp),
            onClick = { viewModel.onBarClick(it) },
            onClick =
                if (enabled) {
                    { viewModel.onBarClick(it) }
                } else {
                    null
                },
        ) { _ ->
            Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) {
                connectedDeviceViewModel?.let { ConnectedDeviceText(it) }
+29 −0
Original line number Diff line number Diff line
@@ -40,6 +40,7 @@ import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
import com.android.systemui.volume.data.repository.audioRepository
import com.android.systemui.volume.data.repository.audioSharingRepository
import com.android.systemui.volume.domain.model.AudioOutputDevice
import com.android.systemui.volume.localMediaController
import com.android.systemui.volume.localMediaRepository
@@ -250,4 +251,32 @@ class AudioOutputInteractorTest : SysuiTestCase() {
                whenever(cachedDevice).thenReturn(cachedBluetoothDevice)
            }
    }

    @Test
    fun inAudioSharing_returnTrue() {
        with(kosmos) {
            testScope.runTest {
                audioSharingRepository.setInAudioSharing(true)

                val inAudioSharing by collectLastValue(underTest.isInAudioSharing)
                runCurrent()

                assertThat(inAudioSharing).isTrue()
            }
        }
    }

    @Test
    fun notInAudioSharing_returnFalse() {
        with(kosmos) {
            testScope.runTest {
                audioSharingRepository.setInAudioSharing(false)

                val inAudioSharing by collectLastValue(underTest.isInAudioSharing)
                runCurrent()

                assertThat(inAudioSharing).isFalse()
            }
        }
    }
}
+22 −0
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ import com.android.systemui.res.R
import com.android.systemui.testKosmos
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
import com.android.systemui.volume.data.repository.audioSharingRepository
import com.android.systemui.volume.domain.interactor.audioModeInteractor
import com.android.systemui.volume.domain.interactor.audioOutputInteractor
import com.android.systemui.volume.localMediaController
@@ -78,6 +79,7 @@ class MediaOutputViewModelTest : SysuiTestCase() {
                    R.string.media_output_title_without_playing,
                    "media_output_title_without_playing"
                )
                addOverride(R.string.audio_sharing_description, "audio_sharing")
            }

            whenever(localMediaController.packageName).thenReturn("test.pkg")
@@ -124,4 +126,24 @@ class MediaOutputViewModelTest : SysuiTestCase() {
            }
        }
    }

    @Test
    fun notPlaying_inAudioSharing_deviceNameSetToAudioSharing() {
        with(kosmos) {
            testScope.runTest {
                playbackStateBuilder.setState(PlaybackState.STATE_STOPPED, 0, 0f)
                localMediaRepository.updateCurrentConnectedDevice(
                    mock { whenever(name).thenReturn("test_device") }
                )
                audioSharingRepository.setInAudioSharing(true)

                val connectedDeviceViewModel by collectLastValue(underTest.connectedDeviceViewModel)
                runCurrent()

                assertThat(connectedDeviceViewModel!!.label)
                    .isEqualTo("media_output_title_without_playing")
                assertThat(connectedDeviceViewModel!!.deviceName).isEqualTo("audio_sharing")
            }
        }
    }
}
Loading