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

Commit 23affd99 authored by chelseahao's avatar chelseahao Committed by Chelsea Hao
Browse files

Handle bluetooth callback and toggle switch, also moved `getDeviceItems` to background thread.

The dialog opens first with empty device list, then the list updates after `getDeviceItems` finishes.

Flag: BLUETOOTH_QS_TILE_DIALOG
Test: atest -c BluetoothTileDialogTest BluetoothTileDialogViewModelTest DeviceItemFactoryTest DeviceItemInteractorTest BluetoothTileDialogRepositoryTest BluetoothStateInteractorTest
Bug: b/298124674 b/299400510
Change-Id: Ib7d4ab9773b7741caf474a273c45d9568eb52566
parent f8a69144
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -114,7 +114,8 @@
            android:id="@+id/see_all_layout"
            style="@style/BluetoothTileDialog.Device"
            android:layout_height="64dp"
            android:paddingStart="20dp">
            android:paddingStart="20dp"
            android:visibility="gone">

            <FrameLayout
                android:layout_width="24dp"
+80 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.qs.tiles.dialog.bluetooth

import android.bluetooth.BluetoothAdapter.STATE_OFF
import android.bluetooth.BluetoothAdapter.STATE_ON
import com.android.settingslib.bluetooth.BluetoothCallback
import com.android.settingslib.bluetooth.LocalBluetoothManager
import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging
import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn

/** Holds business logic for the Bluetooth Dialog's bluetooth and device connection state */
@SysUISingleton
internal class BluetoothStateInteractor
@Inject
constructor(
    private val localBluetoothManager: LocalBluetoothManager?,
    @Application private val coroutineScope: CoroutineScope,
) {

    internal val updateBluetoothStateFlow: StateFlow<Boolean?> =
        conflatedCallbackFlow {
                val listener =
                    object : BluetoothCallback {
                        override fun onBluetoothStateChanged(bluetoothState: Int) {
                            if (bluetoothState == STATE_ON || bluetoothState == STATE_OFF) {
                                super.onBluetoothStateChanged(bluetoothState)
                                trySendWithFailureLogging(
                                    bluetoothState == STATE_ON,
                                    TAG,
                                    "onBluetoothStateChanged"
                                )
                            }
                        }
                    }
                localBluetoothManager?.eventManager?.registerCallback(listener)
                awaitClose { localBluetoothManager?.eventManager?.unregisterCallback(listener) }
            }
            .stateIn(
                coroutineScope,
                SharingStarted.WhileSubscribed(replayExpirationMillis = 0),
                initialValue = null
            )

    internal var isBluetoothEnabled: Boolean
        get() = localBluetoothManager?.bluetoothAdapter?.isEnabled == true
        set(value) {
            if (isBluetoothEnabled != value) {
                localBluetoothManager?.bluetoothAdapter?.apply {
                    if (value) enable() else disable()
                }
            }
        }

    companion object {
        private const val TAG = "BtStateInteractor"
    }
}
+69 −23
Original line number Diff line number Diff line
@@ -20,55 +20,98 @@ import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.Switch
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.res.R
import com.android.systemui.statusbar.phone.SystemUIDialog
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow

/** Dialog for showing active, connected and saved bluetooth devices. */
@SysUISingleton
internal class BluetoothTileDialog
constructor(
    deviceItem: List<DeviceItem>,
    deviceItemOnClickCallback: DeviceItemOnClickCallback,
    private val bluetoothToggleInitialValue: Boolean,
    private val bluetoothTileDialogCallback: BluetoothTileDialogCallback,
    context: Context,
) : SystemUIDialog(context, DEFAULT_THEME, DEFAULT_DISMISS_ON_DEVICE_LOCK) {

    private val deviceItemAdapter: Adapter =
        Adapter(deviceItem.toMutableList(), deviceItemOnClickCallback)
    private val mutableBluetoothStateSwitchedFlow: MutableStateFlow<Boolean?> =
        MutableStateFlow(null)
    internal val bluetoothStateSwitchedFlow
        get() = mutableBluetoothStateSwitchedFlow.asStateFlow()

    private val mutableClickedFlow: MutableSharedFlow<Pair<DeviceItem, Int>> =
        MutableSharedFlow(extraBufferCapacity = 1)
    internal val deviceItemClickedFlow
        get() = mutableClickedFlow.asSharedFlow()

    private val deviceItemAdapter: Adapter = Adapter()

    private lateinit var toggleView: Switch
    private lateinit var doneButton: View
    private lateinit var seeAllView: View
    private lateinit var deviceListView: RecyclerView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(LayoutInflater.from(context).inflate(R.layout.bluetooth_tile_dialog, null))

        setupDoneButton()
        toggleView = requireViewById(R.id.bluetooth_toggle)
        doneButton = requireViewById(R.id.done_button)
        seeAllView = requireViewById(R.id.see_all_layout)
        deviceListView = requireViewById<RecyclerView>(R.id.device_list)

        setupToggle()
        setupRecyclerView()

        doneButton.setOnClickListener { dismiss() }
    }

    internal fun onDeviceItemUpdated(deviceItem: DeviceItem, position: Int) {
    internal fun onDeviceItemUpdated(deviceItem: List<DeviceItem>, showSeeAll: Boolean) {
        seeAllView.visibility = if (showSeeAll) VISIBLE else GONE
        deviceItemAdapter.refreshDeviceItemList(deviceItem)
    }

    internal fun onDeviceItemUpdatedAtPosition(deviceItem: DeviceItem, position: Int) {
        deviceItemAdapter.refreshDeviceItem(deviceItem, position)
    }

    private fun setupDoneButton() {
        requireViewById<View>(R.id.done_button).setOnClickListener { dismiss() }
    internal fun onBluetoothStateUpdated(isEnabled: Boolean) {
        toggleView.isChecked = isEnabled
    }

    private fun setupToggle() {
        toggleView.isChecked = bluetoothToggleInitialValue
        toggleView.setOnCheckedChangeListener { _, isChecked ->
            mutableBluetoothStateSwitchedFlow.value = isChecked
        }
    }

    private fun setupRecyclerView() {
        requireViewById<RecyclerView>(R.id.device_list).apply {
        deviceListView.apply {
            layoutManager = LinearLayoutManager(context)
            adapter = deviceItemAdapter
        }
    }

    internal class Adapter(
        private var deviceItem: MutableList<DeviceItem>,
        private val onClickCallback: DeviceItemOnClickCallback
    ) : RecyclerView.Adapter<Adapter.DeviceItemViewHolder>() {
    internal inner class Adapter : RecyclerView.Adapter<Adapter.DeviceItemViewHolder>() {

        init {
            setHasStableIds(true)
        }

        private val deviceItem: MutableList<DeviceItem> = mutableListOf()

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DeviceItemViewHolder {
            val view =
@@ -79,36 +122,38 @@ constructor(

        override fun getItemCount() = deviceItem.size

        override fun getItemId(position: Int) = position.toLong()

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

        internal fun getItem(position: Int) = deviceItem[position]

        internal fun refreshDeviceItemList(updated: List<DeviceItem>) {
            deviceItem.clear()
            deviceItem.addAll(updated)
            notifyDataSetChanged()
        }

        internal fun refreshDeviceItem(updated: DeviceItem, position: Int) {
            deviceItem[position] = updated
            notifyItemChanged(position)
        }

        internal class DeviceItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        internal inner class DeviceItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
            private val container = view.requireViewById<View>(R.id.bluetooth_device)
            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)

            internal fun bind(
                item: DeviceItem,
                position: Int,
                deviceItemOnClickCallback: DeviceItemOnClickCallback
            ) {
            internal fun bind(item: DeviceItem, position: Int) {
                container.apply {
                    isEnabled = item.isEnabled
                    alpha = item.alpha
                    background = item.background
                    setOnClickListener {
                        deviceItemOnClickCallback.onDeviceItemClicked(item, position)
                    }
                    setOnClickListener { mutableClickedFlow.tryEmit(Pair(item, position)) }
                }
                nameView.text = item.deviceName
                summaryView.text = item.connectionSummary
@@ -125,5 +170,6 @@ constructor(
    internal companion object {
        const val ENABLED_ALPHA = 1.0f
        const val DISABLED_ALPHA = 0.3f
        const val MAX_DEVICE_ITEM_ENTRY = 3
    }
}
+80 −17
Original line number Diff line number Diff line
@@ -17,13 +17,22 @@
package com.android.systemui.qs.tiles.dialog.bluetooth

import android.content.Context
import android.os.Handler
import android.view.View
import androidx.annotation.VisibleForTesting
import com.android.systemui.animation.DialogLaunchAnimator
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.qs.tiles.dialog.bluetooth.BluetoothTileDialog.Companion.MAX_DEVICE_ITEM_ENTRY
import com.android.systemui.statusbar.phone.SystemUIDialog
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch

/** ViewModel for Bluetooth Dialog after clicking on the Bluetooth QS tile. */
@SysUISingleton
@@ -31,13 +40,15 @@ internal class BluetoothTileDialogViewModel
@Inject
constructor(
    private val deviceItemInteractor: DeviceItemInteractor,
    private val bluetoothStateInteractor: BluetoothStateInteractor,
    private val dialogLaunchAnimator: DialogLaunchAnimator,
    @Main private val uiHandler: Handler
) : DeviceItemOnClickCallback {
    private var deviceItems: List<DeviceItem> = emptyList()
    @Application private val coroutineScope: CoroutineScope,
    @Main private val mainDispatcher: CoroutineDispatcher,
) : BluetoothTileDialogCallback {

    @VisibleForTesting
    var dialog: BluetoothTileDialog? = null
    private var job: Job? = null

    @VisibleForTesting internal var dialog: BluetoothTileDialog? = null

    /**
     * Shows the dialog.
@@ -48,27 +59,79 @@ constructor(
    fun showDialog(context: Context, view: View?) {
        dismissDialog()

        deviceItems = deviceItemInteractor.getDeviceItems(context)

        uiHandler.post {
            dialog = BluetoothTileDialog(deviceItems, this, context)
        var updateDeviceItemJob: Job? = null

        job =
            coroutineScope.launch(mainDispatcher) {
                dialog = createBluetoothTileDialog(context)
                view?.let { dialogLaunchAnimator.showFromView(dialog!!, it) } ?: dialog!!.show()
                updateDeviceItemJob?.cancel()
                updateDeviceItemJob = launch { deviceItemInteractor.updateDeviceItems(context) }

                bluetoothStateInteractor.updateBluetoothStateFlow
                    .filterNotNull()
                    .onEach {
                        dialog!!.onBluetoothStateUpdated(it)
                        updateDeviceItemJob?.cancel()
                        updateDeviceItemJob = launch {
                            deviceItemInteractor.updateDeviceItems(context)
                        }
                    }
                    .launchIn(this)

    override fun onDeviceItemClicked(deviceItem: DeviceItem, position: Int) {
        if (deviceItemInteractor.updateDeviceItemOnClick(deviceItem)) {
            dialog?.onDeviceItemUpdated(deviceItem, position)
                deviceItemInteractor.updateDeviceItemsFlow
                    .onEach {
                        updateDeviceItemJob?.cancel()
                        updateDeviceItemJob = launch {
                            deviceItemInteractor.updateDeviceItems(context)
                        }
                    }
                    .launchIn(this)

                deviceItemInteractor.deviceItemFlow
                    .filterNotNull()
                    .onEach {
                        dialog!!.onDeviceItemUpdated(
                            it.take(MAX_DEVICE_ITEM_ENTRY),
                            showSeeAll = it.size > MAX_DEVICE_ITEM_ENTRY
                        )
                    }
                    .launchIn(this)

                dialog!!
                    .bluetoothStateSwitchedFlow
                    .filterNotNull()
                    .onEach { bluetoothStateInteractor.isBluetoothEnabled = it }
                    .launchIn(this)

                dialog!!
                    .deviceItemClickedFlow
                    .onEach {
                        if (deviceItemInteractor.updateDeviceItemOnClick(it.first)) {
                            dialog!!.onDeviceItemUpdatedAtPosition(it.first, it.second)
                        }
                    }
                    .launchIn(this)
            }
    }

    private fun createBluetoothTileDialog(context: Context): BluetoothTileDialog {
        return BluetoothTileDialog(
                bluetoothStateInteractor.isBluetoothEnabled,
                this@BluetoothTileDialogViewModel,
                context
            )
            .apply { SystemUIDialog.registerDismissListener(this) { dismissDialog() } }
    }

    private fun dismissDialog() {
        job?.cancel()
        job = null
        dialog?.dismiss()
        dialog = null
    }
}

internal interface DeviceItemOnClickCallback {
    fun onDeviceItemClicked(deviceItem: DeviceItem, position: Int)
internal interface BluetoothTileDialogCallback {
    // TODO(b/298124674): Add click events for gear, see all and pair new device.
}
+84 −10
Original line number Diff line number Diff line
@@ -20,9 +20,25 @@ import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.media.AudioManager
import com.android.settingslib.bluetooth.BluetoothCallback
import com.android.settingslib.bluetooth.BluetoothUtils
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.settingslib.bluetooth.LocalBluetoothManager
import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging
import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.withContext

/** Holds business logic for the Bluetooth Dialog after clicking on the Bluetooth QS tile. */
@SysUISingleton
@@ -31,8 +47,59 @@ internal class DeviceItemInteractor
constructor(
    private val bluetoothTileDialogRepository: BluetoothTileDialogRepository,
    private val audioManager: AudioManager,
    private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter()
    private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter(),
    private val localBluetoothManager: LocalBluetoothManager?,
    @Application private val coroutineScope: CoroutineScope,
    @Background private val backgroundDispatcher: CoroutineDispatcher,
) {

    private val mutableDeviceItemFlow: MutableStateFlow<List<DeviceItem>?> = MutableStateFlow(null)
    internal val deviceItemFlow
        get() = mutableDeviceItemFlow.asStateFlow()

    internal val updateDeviceItemsFlow: SharedFlow<Unit> =
        conflatedCallbackFlow {
                val listener =
                    object : BluetoothCallback {
                        override fun onActiveDeviceChanged(
                            activeDevice: CachedBluetoothDevice?,
                            bluetoothProfile: Int
                        ) {
                            super.onActiveDeviceChanged(activeDevice, bluetoothProfile)
                            trySendWithFailureLogging(Unit, TAG, "onActiveDeviceChanged")
                        }

                        override fun onConnectionStateChanged(
                            cachedDevice: CachedBluetoothDevice?,
                            state: Int
                        ) {
                            super.onConnectionStateChanged(cachedDevice, state)
                            trySendWithFailureLogging(Unit, TAG, "onConnectionStateChanged")
                        }

                        override fun onDeviceAdded(cachedDevice: CachedBluetoothDevice) {
                            super.onDeviceAdded(cachedDevice)
                            trySendWithFailureLogging(Unit, TAG, "onDeviceAdded")
                        }

                        override fun onProfileConnectionStateChanged(
                            cachedDevice: CachedBluetoothDevice,
                            state: Int,
                            bluetoothProfile: Int
                        ) {
                            super.onProfileConnectionStateChanged(
                                cachedDevice,
                                state,
                                bluetoothProfile
                            )
                            trySendWithFailureLogging(Unit, TAG, "onProfileConnectionStateChanged")
                        }
                    }
                localBluetoothManager?.eventManager?.registerCallback(listener)
                awaitClose { localBluetoothManager?.eventManager?.unregisterCallback(listener) }
            }
            .shareIn(coroutineScope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0))

    private var deviceItemFactoryList: List<DeviceItemFactory> =
        listOf(
            AvailableMediaDeviceItemFactory(),
@@ -47,10 +114,12 @@ constructor(
            DeviceItemType.SAVED_BLUETOOTH_DEVICE,
        )

    internal fun getDeviceItems(context: Context): List<DeviceItem> {
    internal suspend fun updateDeviceItems(context: Context) {
        withContext(backgroundDispatcher) {
            val mostRecentlyConnectedDevices = bluetoothAdapter?.mostRecentlyConnectedDevices

        return bluetoothTileDialogRepository.cachedDevices
            mutableDeviceItemFlow.value =
                bluetoothTileDialogRepository.cachedDevices
                    .mapNotNull { cachedDevice ->
                        deviceItemFactoryList
                            .firstOrNull { it.isFilterMatched(cachedDevice, audioManager) }
@@ -58,6 +127,7 @@ constructor(
                    }
                    .sort(displayPriority, mostRecentlyConnectedDevices)
        }
    }

    private fun List<DeviceItem>.sort(
        displayPriority: List<DeviceItemType>,
@@ -100,4 +170,8 @@ constructor(
    internal fun setDisplayPriorityForTesting(list: List<DeviceItemType>) {
        displayPriority = list
    }

    companion object {
        private const val TAG = "DeviceItemInteractor"
    }
}
Loading