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

Commit aaefed75 authored by chelseahao's avatar chelseahao
Browse files

Replace gear button with "+" button for audio sharing available devices, and...

Replace gear button with "+" button for audio sharing available devices, and check mark for devices in audio sharing session.

For this CL, clicking on them still go to settings. Behavior change will be in the next CL.

Test: atest
Bug: 382397280
Flag: com.android.settingslib.flags.enable_le_audio_sharing
Change-Id: Iebc43ad26513087381730ba6b92e862068958f14
parent e4046a28
Loading
Loading
Loading
Loading
+121 −5
Original line number Diff line number Diff line
@@ -18,12 +18,20 @@ package com.android.systemui.bluetooth.qsdialog

import android.bluetooth.BluetoothLeBroadcast
import android.bluetooth.BluetoothLeBroadcastMetadata
import android.content.ContentResolver
import android.content.applicationContext
import android.testing.TestableLooper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.settingslib.bluetooth.BluetoothEventManager
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager
import com.android.settingslib.bluetooth.VolumeControlProfile
import com.android.settingslib.volume.shared.AudioSharingLogger
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.testDispatcher
import com.android.systemui.kosmos.testScope
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
@@ -38,10 +46,16 @@ import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Captor
import org.mockito.Mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.spy
import org.mockito.kotlin.times
import org.mockito.kotlin.whenever

@SmallTest
@RunWith(AndroidJUnit4::class)
@@ -50,8 +64,11 @@ import org.mockito.kotlin.any
class AudioSharingInteractorTest : SysuiTestCase() {
    @get:Rule val mockito: MockitoRule = MockitoJUnit.rule()
    private val kosmos = testKosmos()

    @Mock private lateinit var localBluetoothLeBroadcast: LocalBluetoothLeBroadcast

    @Mock private lateinit var bluetoothLeBroadcastMetadata: BluetoothLeBroadcastMetadata

    @Captor private lateinit var callbackCaptor: ArgumentCaptor<BluetoothLeBroadcast.Callback>
    private lateinit var underTest: AudioSharingInteractor

@@ -157,13 +174,15 @@ class AudioSharingInteractorTest : SysuiTestCase() {
    fun testHandleAudioSourceWhenReady_hasProfileButAudioSharingOff_sourceNotAdded() =
        with(kosmos) {
            testScope.runTest {
                bluetoothTileDialogAudioSharingRepository.setInAudioSharing(false)
                bluetoothTileDialogAudioSharingRepository.setInAudioSharing(true)
                bluetoothTileDialogAudioSharingRepository.setAudioSharingAvailable(true)
                bluetoothTileDialogAudioSharingRepository.setLeAudioBroadcastProfile(
                    localBluetoothLeBroadcast
                )
                val job = launch { underTest.handleAudioSourceWhenReady() }
                runCurrent()
                bluetoothTileDialogAudioSharingRepository.setInAudioSharing(false)
                runCurrent()

                assertThat(bluetoothTileDialogAudioSharingRepository.sourceAdded).isFalse()
                job.cancel()
@@ -174,15 +193,14 @@ class AudioSharingInteractorTest : SysuiTestCase() {
    fun testHandleAudioSourceWhenReady_audioSharingOnButNoPlayback_sourceNotAdded() =
        with(kosmos) {
            testScope.runTest {
                bluetoothTileDialogAudioSharingRepository.setInAudioSharing(true)
                bluetoothTileDialogAudioSharingRepository.setInAudioSharing(false)
                bluetoothTileDialogAudioSharingRepository.setAudioSharingAvailable(true)
                bluetoothTileDialogAudioSharingRepository.setLeAudioBroadcastProfile(
                    localBluetoothLeBroadcast
                )
                val job = launch { underTest.handleAudioSourceWhenReady() }
                runCurrent()
                verify(localBluetoothLeBroadcast)
                    .registerServiceCallBack(any(), callbackCaptor.capture())
                bluetoothTileDialogAudioSharingRepository.setInAudioSharing(true)
                runCurrent()

                assertThat(bluetoothTileDialogAudioSharingRepository.sourceAdded).isFalse()
@@ -194,13 +212,15 @@ class AudioSharingInteractorTest : SysuiTestCase() {
    fun testHandleAudioSourceWhenReady_audioSharingOnAndPlaybackStarts_sourceAdded() =
        with(kosmos) {
            testScope.runTest {
                bluetoothTileDialogAudioSharingRepository.setInAudioSharing(true)
                bluetoothTileDialogAudioSharingRepository.setInAudioSharing(false)
                bluetoothTileDialogAudioSharingRepository.setAudioSharingAvailable(true)
                bluetoothTileDialogAudioSharingRepository.setLeAudioBroadcastProfile(
                    localBluetoothLeBroadcast
                )
                val job = launch { underTest.handleAudioSourceWhenReady() }
                runCurrent()
                bluetoothTileDialogAudioSharingRepository.setInAudioSharing(true)
                runCurrent()
                verify(localBluetoothLeBroadcast)
                    .registerServiceCallBack(any(), callbackCaptor.capture())
                runCurrent()
@@ -211,4 +231,100 @@ class AudioSharingInteractorTest : SysuiTestCase() {
                job.cancel()
            }
        }

    @Test
    fun testHandleAudioSourceWhenReady_skipInitialValue_noAudioSharing_sourceNotAdded() =
        with(kosmos) {
            testScope.runTest {
                val (broadcast, repository) = setupRepositoryImpl()
                val interactor =
                    object :
                        AudioSharingInteractorImpl(
                            applicationContext,
                            localBluetoothManager,
                            repository,
                            testDispatcher,
                        ) {
                        override suspend fun audioSharingAvailable() = true
                    }
                val job = launch { interactor.handleAudioSourceWhenReady() }
                runCurrent()
                // Verify callback registered for onBroadcastStartedOrStopped
                verify(broadcast).registerServiceCallBack(any(), callbackCaptor.capture())
                runCurrent()
                // Verify source is not added
                verify(repository, never()).addSource()
                job.cancel()
            }
        }

    @Test
    fun testHandleAudioSourceWhenReady_skipInitialValue_newAudioSharing_sourceAdded() =
        with(kosmos) {
            testScope.runTest {
                val (broadcast, repository) = setupRepositoryImpl()
                val interactor =
                    object :
                        AudioSharingInteractorImpl(
                            applicationContext,
                            localBluetoothManager,
                            repository,
                            testDispatcher,
                        ) {
                        override suspend fun audioSharingAvailable() = true
                    }
                val job = launch { interactor.handleAudioSourceWhenReady() }
                runCurrent()
                // Verify callback registered for onBroadcastStartedOrStopped
                verify(broadcast).registerServiceCallBack(any(), callbackCaptor.capture())
                // Audio sharing started, trigger onBroadcastStarted
                whenever(broadcast.isEnabled(null)).thenReturn(true)
                callbackCaptor.value.onBroadcastStarted(0, 0)
                runCurrent()
                // Verify callback registered for onBroadcastMetadataChanged
                verify(broadcast, times(2)).registerServiceCallBack(any(), callbackCaptor.capture())
                runCurrent()
                // Trigger onBroadcastMetadataChanged (ready to add source)
                callbackCaptor.value.onBroadcastMetadataChanged(0, bluetoothLeBroadcastMetadata)
                runCurrent()
                // Verify source added
                verify(repository).addSource()
                job.cancel()
            }
        }

    private fun setupRepositoryImpl(): Pair<LocalBluetoothLeBroadcast, AudioSharingRepositoryImpl> {
        with(kosmos) {
            val broadcast =
                mock<LocalBluetoothLeBroadcast> {
                    on { isProfileReady } doReturn true
                    on { isEnabled(null) } doReturn false
                }
            val assistant =
                mock<LocalBluetoothLeBroadcastAssistant> { on { isProfileReady } doReturn true }
            val volumeControl = mock<VolumeControlProfile> { on { isProfileReady } doReturn true }
            val profileManager =
                mock<LocalBluetoothProfileManager> {
                    on { leAudioBroadcastProfile } doReturn broadcast
                    on { leAudioBroadcastAssistantProfile } doReturn assistant
                    on { volumeControlProfile } doReturn volumeControl
                }
            whenever(localBluetoothManager.profileManager).thenReturn(profileManager)
            whenever(localBluetoothManager.eventManager).thenReturn(mock<BluetoothEventManager> {})

            val repository =
                AudioSharingRepositoryImpl(
                    localBluetoothManager,
                    com.android.settingslib.volume.data.repository.AudioSharingRepositoryImpl(
                        mock<ContentResolver> {},
                        localBluetoothManager,
                        testScope.backgroundScope,
                        testScope.testScheduler,
                        mock<AudioSharingLogger> {},
                    ),
                    testDispatcher,
                )
            return Pair(broadcast, spy(repository))
        }
    }
}
+5 −1
Original line number Diff line number Diff line
@@ -29,6 +29,7 @@ import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
@@ -61,7 +62,7 @@ interface AudioSharingInteractor {

@SysUISingleton
@OptIn(ExperimentalCoroutinesApi::class)
class AudioSharingInteractorImpl
open class AudioSharingInteractorImpl
@Inject
constructor(
    private val context: Context,
@@ -99,6 +100,9 @@ constructor(
            if (audioSharingAvailable()) {
                audioSharingRepository.leAudioBroadcastProfile?.let { profile ->
                    isAudioSharingOn
                        // Skip the default value, we only care about adding source for newly
                        // started audio sharing session
                        .drop(1)
                        .mapNotNull { audioSharingOn ->
                            if (audioSharingOn) {
                                // onBroadcastMetadataChanged could emit multiple times during one
+23 −15
Original line number Diff line number Diff line
@@ -56,6 +56,13 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext

data class DeviceItemClick(val deviceItem: DeviceItem, val clickedView: View, val target: Target) {
    enum class Target {
        ENTIRE_ROW,
        ACTION_ICON,
    }
}

/** Dialog for showing active, connected and saved bluetooth devices. */
class BluetoothTileDialogDelegate
@AssistedInject
@@ -80,7 +87,7 @@ internal constructor(
    internal val bluetoothAutoOnToggle
        get() = mutableBluetoothAutoOnToggle.asStateFlow()

    private val mutableDeviceItemClick: MutableSharedFlow<DeviceItem> =
    private val mutableDeviceItemClick: MutableSharedFlow<DeviceItemClick> =
        MutableSharedFlow(extraBufferCapacity = 1)
    internal val deviceItemClick
        get() = mutableDeviceItemClick.asSharedFlow()
@@ -90,7 +97,7 @@ internal constructor(
    internal val contentHeight
        get() = mutableContentHeight.asSharedFlow()

    private val deviceItemAdapter: Adapter = Adapter(bluetoothTileDialogCallback)
    private val deviceItemAdapter: Adapter = Adapter()

    private var lastUiUpdateMs: Long = -1

@@ -334,8 +341,7 @@ internal constructor(
        }
    }

    internal inner class Adapter(private val onClickCallback: BluetoothTileDialogCallback) :
        RecyclerView.Adapter<Adapter.DeviceItemViewHolder>() {
    internal inner class Adapter : RecyclerView.Adapter<Adapter.DeviceItemViewHolder>() {

        private val diffUtilCallback =
            object : DiffUtil.ItemCallback<DeviceItem>() {
@@ -376,7 +382,7 @@ internal constructor(

        override fun onBindViewHolder(holder: DeviceItemViewHolder, position: Int) {
            val item = getItem(position)
            holder.bind(item, onClickCallback)
            holder.bind(item)
        }

        internal fun getItem(position: Int) = asyncListDiffer.currentList[position]
@@ -390,19 +396,18 @@ internal constructor(
            private val nameView = view.requireViewById<TextView>(R.id.bluetooth_device_name)
            private val summaryView = view.requireViewById<TextView>(R.id.bluetooth_device_summary)
            private val iconView = view.requireViewById<ImageView>(R.id.bluetooth_device_icon)
            private val iconGear = view.requireViewById<ImageView>(R.id.gear_icon_image)
            private val gearView = view.requireViewById<View>(R.id.gear_icon)
            private val actionIcon = view.requireViewById<ImageView>(R.id.gear_icon_image)
            private val actionIconView = view.requireViewById<View>(R.id.gear_icon)
            private val divider = view.requireViewById<View>(R.id.divider)

            internal fun bind(
                item: DeviceItem,
                deviceItemOnClickCallback: BluetoothTileDialogCallback,
            ) {
            internal fun bind(item: DeviceItem) {
                container.apply {
                    isEnabled = item.isEnabled
                    background = item.background?.let { context.getDrawable(it) }
                    setOnClickListener {
                        mutableDeviceItemClick.tryEmit(item)
                        mutableDeviceItemClick.tryEmit(
                            DeviceItemClick(item, it, DeviceItemClick.Target.ENTIRE_ROW)
                        )
                        uiEventLogger.log(BluetoothTileDialogUiEvent.DEVICE_CLICKED)
                    }

@@ -421,7 +426,8 @@ internal constructor(
                        }
                    }

                    iconGear.apply { drawable?.let { it.mutate()?.setTint(tintColor) } }
                    actionIcon.setImageResource(item.actionIconRes)
                    actionIcon.drawable?.setTint(tintColor)

                    divider.setBackgroundColor(tintColor)

@@ -454,8 +460,10 @@ internal constructor(
                nameView.text = item.deviceName
                summaryView.text = item.connectionSummary

                gearView.setOnClickListener {
                    deviceItemOnClickCallback.onDeviceItemGearClicked(item, it)
                actionIconView.setOnClickListener {
                    mutableDeviceItemClick.tryEmit(
                        DeviceItemClick(item, it, DeviceItemClick.Target.ACTION_ICON)
                    )
                }
            }
        }
+16 −5
Original line number Diff line number Diff line
@@ -227,8 +227,21 @@ constructor(
                // deviceItemClick is emitted when user clicked on a device item.
                dialogDelegate.deviceItemClick
                    .onEach {
                        deviceItemActionInteractor.onClick(it, dialog)
                        logger.logDeviceClick(it.cachedBluetoothDevice.address, it.type)
                        when (it.target) {
                            DeviceItemClick.Target.ENTIRE_ROW -> {
                                deviceItemActionInteractor.onClick(it.deviceItem, dialog)
                                logger.logDeviceClick(
                                    it.deviceItem.cachedBluetoothDevice.address,
                                    it.deviceItem.type,
                                )
                            }

                            DeviceItemClick.Target.ACTION_ICON -> {
                                // TODO(b/382397280): Move this to DeviceItemActionInteractor, and
                                // handle click events according to device item type
                                onDeviceItemGearClicked(it.deviceItem, it.clickedView)
                            }
                        }
                    }
                    .launchIn(this)

@@ -287,7 +300,7 @@ constructor(
        )
    }

    override fun onDeviceItemGearClicked(deviceItem: DeviceItem, view: View) {
    private fun onDeviceItemGearClicked(deviceItem: DeviceItem, view: View) {
        uiEventLogger.log(BluetoothTileDialogUiEvent.DEVICE_GEAR_CLICKED)
        val intent =
            Intent(ACTION_BLUETOOTH_DEVICE_DETAILS).apply {
@@ -382,8 +395,6 @@ constructor(
}

interface BluetoothTileDialogCallback {
    fun onDeviceItemGearClicked(deviceItem: DeviceItem, view: View)

    fun onSeeAllClicked(view: View)

    fun onPairNewDeviceClicked(view: View)
+2 −1
Original line number Diff line number Diff line
@@ -53,5 +53,6 @@ data class DeviceItem(
    val background: Int? = null,
    var isEnabled: Boolean = true,
    var actionAccessibilityLabel: String = "",
    var isActive: Boolean = false
    var isActive: Boolean = false,
    val actionIconRes: Int = -1,
)
Loading