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

Commit acf79670 authored by Angela Wang's avatar Angela Wang Committed by Android (Google) Code Review
Browse files

Merge "Preset: The controller updates the preset UI with remote status" into main

parents 46bbc987 2a728038
Loading
Loading
Loading
Loading
+3 −1
Original line number Diff line number Diff line
@@ -244,8 +244,10 @@
    <string name="bluetooth_hearing_aid_right_active">Active (right only)</string>
    <!-- Connected device settings. Message when the left-side and right-side hearing aids device are active. [CHAR LIMIT=NONE] -->
    <string name="bluetooth_hearing_aid_left_and_right_active">Active (left and right)</string>
    <!-- Connected device settings.: Message when changing remote ambient state failed. [CHAR LIMIT=NONE] -->
    <!-- Connected device settings. Message when changing remote ambient state failed. [CHAR LIMIT=NONE] -->
    <string name="bluetooth_hearing_device_ambient_error">Couldn\u2019t update surroundings</string>
    <!-- Connected device settings. Message when selecting hearing device preset failed. [CHAR LIMIT=NONE] -->
    <string name="bluetooth_hearing_aids_presets_error">Couldn\u2019t update preset</string>

    <!-- Connected devices settings. Message when Bluetooth is connected and active for media only, showing remote device status and battery level. [CHAR LIMIT=NONE] -->
    <string name="bluetooth_active_media_only_battery_level">Active (media only). <xliff:g id="battery_level_as_percentage">%1$s</xliff:g> battery.</string>
+98 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.bluetooth.hearingdevices.ui

import android.bluetooth.BluetoothHapPresetInfo

/**
 * Defines the contract for a user interface that controls hearing device presets.
 *
 * <p>This interface serves as the "view" in the architecture, providing methods for a controller
 * to manage the display of preset controls and to receive user interactions.
 */
interface PresetUi : ExpandableControlUi {
    /** Interface definition for a callback to be invoked when event happens in PresetUi. */
    interface PresetUiListener {
        /**
         * Called when the preset of the specified side is changed by a user interaction in the UI.
         *
         * @param side The side of the device (e.g., left, right, or unified).
         * @param value The index of the selected preset.
         */
        fun onPresetChangedFromUi(side: Int, value: Int)
    }

    /**
     * Sets the listener to be invoked when events happen in this UI.
     *
     * @param listener The listener to set. A {@code null} value will unregister the listener.
     * @see PresetUiListener
     */
    fun setListener(listener: PresetUiListener?)

    /**
     * Sets up the preset control in the UI.
     *
     * <p>The UI provides separate controls for each side, along with a single unified control
     * for all sides simultaneously.
     *
     * @param sides A set of device sides in the same set.
     */
    fun setupControls(sides: Set<Int>)

    /**
     * Sets if the specified preset control is enabled.
     *
     * @param side The side of the control to update.
     * @param enabled The enabled state.
     */
    fun setControlEnabled(side: Int, enabled: Boolean)

    /**
     * Sets the available preset options for a specified preset control in the UI.
     *
     * @param side The side of the control to update.
     * @param presetInfos A list of the preset options.
     */
    fun setControlList(side: Int, presetInfos: List<BluetoothHapPresetInfo>)

    /**
     * Sets the selected preset option for a specified preset control in the UI.
     *
     * @param side The side of the control to update.
     * @param presetIndex An index value of the selected preset.
     */
    fun setControlValue(side: Int, presetIndex: Int)

    /**
     * Gets the current preset value from a specified preset control in the UI.
     *
     * @param side The side of the control to get the value from.
     * @return The index value of the current selected preset.
     */
    fun getControlValue(side: Int): Int

    @Override
    override fun setControlExpandable(expandable: Boolean) {
        // This implementation intentionally does nothing to enforce a non-expandable UI.
    }

    @Override
    override fun isControlExpandable(): Boolean {
        return true
    }
}
 No newline at end of file
+325 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.bluetooth.hearingdevices.ui

import android.bluetooth.BluetoothCsipSetCoordinator
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothDevice.BOND_BONDED
import android.bluetooth.BluetoothHapPresetInfo
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.util.Log
import android.widget.Toast
import com.android.settingslib.R
import com.android.settingslib.bluetooth.BluetoothCallback
import com.android.settingslib.bluetooth.BluetoothEventManager
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.settingslib.bluetooth.HapClientProfile
import com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_INVALID
import com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT
import com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT
import com.android.settingslib.bluetooth.LocalBluetoothManager
import com.android.settingslib.bluetooth.hearingdevices.PresetController
import com.android.settingslib.bluetooth.hearingdevices.ui.ExpandableControlUi.Companion.SIDE_UNIFIED
import com.android.settingslib.bluetooth.hearingdevices.ui.ExpandableControlUi.Companion.VALID_SIDES
import com.android.settingslib.utils.ThreadUtils
import com.google.common.collect.BiMap
import com.google.common.collect.HashBiMap

/** This class controls the hearing device preset UI with remote preset data.
 *
 * It acts as a bridge between the UI [PresetUi] and the remote data retrieved from
 * [PresetController], managing the state and ensuring the UI reflects the current
 * remote device settings.
 */
class PresetUiController(
    private val context: Context,
    bluetoothManager: LocalBluetoothManager,
    private val presetLayout: PresetUi
) : PresetController.PresetControlCallback, PresetUi.PresetUiListener {

    private val eventManager: BluetoothEventManager = bluetoothManager.eventManager
    private val sideToDeviceMap: BiMap<Int, BluetoothDevice> = HashBiMap.create()
    private val cachedDevices: MutableSet<CachedBluetoothDevice> = mutableSetOf()
    private val presetController: PresetController =
        PresetController(bluetoothManager.profileManager, this)

    private var cachedDevice: CachedBluetoothDevice? = null
    private var started = false
    private var toast: Toast? = null

    private val bluetoothCallback: BluetoothCallback = object : BluetoothCallback {
        override fun onProfileConnectionStateChanged(
            cachedDevice: CachedBluetoothDevice,
            state: Int,
            bluetoothProfile: Int
        ) {
            if (bluetoothProfile == BluetoothProfile.HAP_CLIENT &&
                state == BluetoothProfile.STATE_CONNECTED &&
                cachedDevices.contains(cachedDevice)
            ) {
                context.mainExecutor.execute { refresh() }
            }
        }
    }

    private val deviceCallback: CachedBluetoothDevice.Callback =
        object : CachedBluetoothDevice.Callback {
            override fun onDeviceAttributesChanged() {
                cachedDevices.forEach { device -> device.unregisterCallback(this) }
                loadDevice(cachedDevice)
                cachedDevices.forEach { device ->
                    device.registerCallback(
                        ThreadUtils.getBackgroundExecutor(),
                        this
                    )
                }
            }
        }

    init {
        presetLayout.setListener(this)
    }

    /**
     * Loads all devices in the same set as [cachedDevice] and prepares the UI.
     *
     * This method ensures the UI is visible only if the device supports HAP, identifies all
     * valid devices in the same set and populates the internal device map.
     *
     * @param cachedDevice The remote device.
     */
    fun loadDevice(cachedDevice: CachedBluetoothDevice?) {
        if (DEBUG) {
            Log.d(TAG, "loadDevice, device=$cachedDevice")
        }
        this.cachedDevice = cachedDevice
        sideToDeviceMap.clear()
        cachedDevices.clear()
        if (cachedDevice == null
            || cachedDevice.profiles.stream().noneMatch { p -> p is HapClientProfile }
        ) {
            context.mainExecutor.execute { presetLayout.setVisible(false) }
            return
        }

        // load devices in the same set
        if (VALID_SIDES.contains(cachedDevice.deviceSide) && cachedDevice.bondState == BOND_BONDED) {
            sideToDeviceMap[cachedDevice.deviceSide] = cachedDevice.device
            cachedDevices.add(cachedDevice)
        }
        for (memberDevice in cachedDevice.memberDevice) {
            if (VALID_SIDES.contains(memberDevice.deviceSide) &&
                memberDevice.bondState == BOND_BONDED
            ) {
                sideToDeviceMap[memberDevice.deviceSide] = memberDevice.device
                cachedDevices.add(memberDevice)
            }
        }

        context.mainExecutor.execute {
            presetLayout.setupControls(sideToDeviceMap.keys)
            if (started) {
                refresh()
            }
        }
    }

    /**
     * Starts the controller.
     *
     * This method registers the necessary callbacks to begin listening for remote events.
     * It should be called when the UI is active and ready to process events.
     */
    fun start() {
        if (started) {
            return
        }
        started = true
        eventManager.registerCallback(bluetoothCallback)
        cachedDevices.forEach { device: CachedBluetoothDevice ->
            device.registerCallback(ThreadUtils.getBackgroundExecutor(), deviceCallback)
        }
        presetController.registerCallback(ThreadUtils.getBackgroundExecutor())
        refresh()
    }

    /**
     * Stops the controller.
     *
     * This method unregisters the callbacks and should be called when the UI is no longer
     * active or does not need to listen for events.
     */
    fun stop() {
        if (!started) {
            return
        }
        started = false
        eventManager.unregisterCallback(bluetoothCallback)
        cachedDevices.forEach { device: CachedBluetoothDevice ->
            device.unregisterCallback(deviceCallback)
        }
        presetController.unregisterCallback()
    }

    /**
     * Refreshes the preset UI with the latest data from the remote devices.
     *
     * This method queries the preset lists from remote and updates the UI accordingly.
     * The UI will display controls for each side if the preset lists differ, and a unified
     * control otherwise.
     */
    fun refresh() {
        val leftDevice: BluetoothDevice? = sideToDeviceMap[SIDE_LEFT]
        val rightDevice: BluetoothDevice? = sideToDeviceMap[SIDE_RIGHT]
        val leftList: List<BluetoothHapPresetInfo> = presetController.getPresetInfos(leftDevice)
        val rightList: List<BluetoothHapPresetInfo> = presetController.getPresetInfos(rightDevice)

        context.mainExecutor.execute {
            if (leftList.isEmpty() && rightList.isEmpty()) {
                presetLayout.setVisible(false)
                return@execute
            }
            presetLayout.setVisible(true)
            if (leftDevice != null && rightDevice != null && isDifferentPresetList(
                    leftList,
                    rightList
                )
            ) {
                loadDataToControl(SIDE_LEFT, leftDevice, leftList)
                loadDataToControl(SIDE_RIGHT, rightDevice, rightList)
            } else {
                val device = cachedDevice?.device
                if (device != null) {
                    loadDataToControl(SIDE_UNIFIED, device, null)
                }
            }
        }
    }

    private fun loadDataToControl(
        side: Int,
        device: BluetoothDevice,
        presetInfos: List<BluetoothHapPresetInfo>?
    ) {
        presetLayout.setControlExpanded(side != SIDE_UNIFIED)
        presetLayout.setControlEnabled(side, device.isConnected)
        val finalPresetInfos = presetInfos ?: presetController.getPresetInfos(device)
        val activePresetIndex = presetController.getActivePreset(device)
        presetLayout.setControlList(side, finalPresetInfos)
        presetLayout.setControlValue(side, activePresetIndex)
    }

    private fun isDifferentPresetList(
        list1: List<BluetoothHapPresetInfo>,
        list2: List<BluetoothHapPresetInfo>
    ): Boolean {
        if (list1.size != list2.size) {
            return true
        }
        return list1.zip(list2).any { (preset1, preset2) ->
            preset1.name != preset2.name || preset1.index != preset2.index
        }
    }

    override fun onHapClientServiceConnected() {
        refresh()
    }

    override fun onPresetChangedFromRemote(device: BluetoothDevice, presetIndex: Int) {
        if (sideToDeviceMap.containsValue(device)) {
            val side = if (presetLayout.isControlExpanded()) {
                sideToDeviceMap.inverse().getOrDefault(device, SIDE_INVALID)
            } else {
                SIDE_UNIFIED
            }
            context.mainExecutor.execute { presetLayout.setControlValue(side, presetIndex) }
        }
    }

    override fun onPresetInfoChangedFromRemote(
        device: BluetoothDevice,
        presetInfos: List<BluetoothHapPresetInfo>
    ) {
        if (sideToDeviceMap.containsValue(device)) {
            val side = if (presetLayout.isControlExpanded()) {
                sideToDeviceMap.inverse().getOrDefault(device, SIDE_INVALID)
            } else {
                SIDE_UNIFIED
            }
            context.mainExecutor.execute { presetLayout.setControlList(side, presetInfos) }
        }
    }

    override fun onPresetGroupSelectionFailedFromRemote(hapGroupId: Int) {
        val device = cachedDevice?.device
        if (device != null) {
            if (hapGroupId == presetController.getHapGroupId(device)) {
                // Try to set the preset independently if group operation failed
                val selectedIndex = presetLayout.getControlValue(SIDE_UNIFIED)
                for (memberDevice in sideToDeviceMap.values) {
                    presetController.selectPreset(memberDevice, selectedIndex)
                }
            }
        }
    }

    override fun onCommandFailedFromRemote() {
        refresh()
        showErrorToast()
    }

    override fun onPresetChangedFromUi(side: Int, value: Int) {
        if (side == SIDE_UNIFIED) {
            val device = cachedDevice?.device
            if (device != null) {
                val hapGroupId = presetController.getHapGroupId(device)
                if (presetController.supportsSynchronizedPresets(device) &&
                    hapGroupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID
                ) {
                    presetController.selectPresetForGroup(hapGroupId, value)
                } else {
                    for (memberDevice in sideToDeviceMap.values) {
                        presetController.selectPreset(memberDevice, value)
                    }
                }
            }
        } else {
            val device: BluetoothDevice? = sideToDeviceMap[side]
            if (device != null) {
                presetController.selectPreset(device, value)
            }
        }
    }

    private fun showErrorToast() {
        context.mainExecutor.execute {
            toast?.cancel()
            toast = Toast.makeText(
                context,
                R.string.bluetooth_hearing_aids_presets_error,
                Toast.LENGTH_SHORT
            )
            toast?.show()
        }
    }

    companion object {
        private const val DEBUG = true
        private const val TAG = "PresetUiController"
    }
}
 No newline at end of file
+309 −0

File added.

Preview size limit exceeded, changes collapsed.