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

Commit 272fe85d authored by Angela Wang's avatar Angela Wang
Browse files

Preset: The controller handles communications with remote device via HAP

Flag: com.android.settingslib.flags.hearing_devices_separated_preset_control
Bug: 417628947
Test: PresetControllerTest

Change-Id: I569354f7d8ec7638a8af214d4210ea41cf87bde8
parent 9feafd75
Loading
Loading
Loading
Loading
+269 −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

import android.bluetooth.BluetoothCsipSetCoordinator
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothHapClient
import android.bluetooth.BluetoothHapPresetInfo
import android.util.Log
import com.android.settingslib.bluetooth.HapClientProfile
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager
import com.android.settingslib.utils.ThreadUtils
import java.util.concurrent.Executor

/**
 * This class serves as an abstraction layer for managing hearing device presets.
 *
 * It handles the communication with remote devices via the Bluetooth Hearing Access Profile
 * (HAP) and reports events back to a higher-level controller through a simplified callback
 * interface.
 */
class PresetController(
    private val profileManager: LocalBluetoothProfileManager,
    private val controlCallback: PresetControlCallback
) : LocalBluetoothProfileManager.ServiceListener {

    private val hapClientProfile: HapClientProfile? = profileManager.hapClientProfile
    private val remoteCallback: PresetCallback = PresetCallback(controlCallback)

    init {
        if (hapClientProfile?.isProfileReady == false) {
            profileManager.addServiceListener(this)
        }
    }

    override fun onServiceConnected() {
        if (hapClientProfile?.isProfileReady == true) {
            profileManager.removeServiceListener(this)
            registerCallback(ThreadUtils.getBackgroundExecutor())
            controlCallback.onHapClientServiceConnected()
        }
    }

    override fun onServiceDisconnected() {
        // Do nothing
    }

    /**
     * Registers the internal callback with the HAP client profile.
     *
     * @param executor The executor where the callback will be invoked.
     * @see unregisterCallback
     */
    fun registerCallback(executor: Executor) {
        try {
            hapClientProfile?.registerCallback(executor, remoteCallback)
        } catch (e: IllegalArgumentException) {
            // The callback was already registered
            Log.i(TAG, "Skip registering the callback, ${e.message}")
        }
    }

    /**
     * Unregisters the internal callback from the HAP client profile.
     */
    fun unregisterCallback() {
        try {
            hapClientProfile?.unregisterCallback(remoteCallback)
        } catch (e: IllegalArgumentException) {
            // The callback was never registered or was already unregistered
            Log.w(TAG, "Cannot unregister callback, ${e.message}")
        }
    }

    /**
     * Gets the active preset index for a specific remote device.
     *
     * @param device The Bluetooth device.
     * @return The active preset index, or [BluetoothHapClient.PRESET_INDEX_UNAVAILABLE]
     * if the profile is not ready.
     */
    fun getActivePreset(device: BluetoothDevice): Int {
        return hapClientProfile?.getActivePresetIndex(device)
            ?: BluetoothHapClient.PRESET_INDEX_UNAVAILABLE
    }

    /**
     * Gets a list of all available presets for a remote device.
     *
     * @param device The Bluetooth device.
     * @return A list of preset information. Returns an empty list if the profile is not ready
     * or the device is null.
     */
    fun getPresetInfos(device: BluetoothDevice?): List<BluetoothHapPresetInfo> {
        if (device == null) {
            return emptyList()
        }
        return hapClientProfile?.getAllPresetInfo(device)
            ?.filter { it.isAvailable }
            ?.sortedBy { it.index }
            .orEmpty()
    }

    /**
     * Sends a command to a remote device to select a specific preset.
     *
     * @param device The Bluetooth device to command.
     * @param presetIndex The index of the preset to select.
     */
    fun selectPreset(device: BluetoothDevice, presetIndex: Int) {
        if (DEBUG) {
            Log.d(TAG, "selectPreset, presetIndex=$presetIndex, device=$device")
        }
        if (!device.isConnected) {
            Log.w(TAG, "selectPreset ignored, device is not connected. device=$device")
            return
        }
        hapClientProfile?.selectPreset(device, presetIndex)
    }

    /**
     * Sends a command to a group of devices to select a specific preset.
     *
     * @param groupId The HAP group ID.
     * @param presetIndex The index of the preset to select.
     */
    fun selectPresetForGroup(groupId: Int, presetIndex: Int) {
        if (DEBUG) {
            Log.d(
                TAG,
                "selectPresetForGroup, presetIndex=$presetIndex, groupId=$groupId"
            )
        }
        hapClientProfile?.selectPresetForGroup(groupId, presetIndex)
    }

    /**
     * Gets the HAP group ID for a specific remote device.
     *
     * @param device The Bluetooth device.
     * @return The HAP group ID.
     */
    fun getHapGroupId(device: BluetoothDevice): Int {
        return hapClientProfile?.getHapGroup(device) ?: BluetoothCsipSetCoordinator.GROUP_ID_INVALID
    }

    /**
     * Checks if a remote device supports synchronized presets.
     *
     * @param device The Bluetooth device.
     * @return `true` if the device supports synchronized presets; `false` otherwise.
     */
    fun supportsSynchronizedPresets(device: BluetoothDevice): Boolean {
        return hapClientProfile?.supportsSynchronizedPresets(device) ?: false
    }

    /**
     * Callback providing information about the status and received events of
     * [PresetController].
     */
    interface PresetControlCallback {
        /**
         * Called when the HAP client service is connected and ready to receive commands.
         */
        fun onHapClientServiceConnected()

        /**
         * Called when the active preset on a remote device has been changed.
         *
         * @param device The remote device that was changed.
         * @param presetIndex The index of the new active preset.
         */
        fun onPresetChangedFromRemote(device: BluetoothDevice, presetIndex: Int)

        /**
         * Called when the list of available presets on a remote device has been updated.
         *
         * @param device The remote device with the updated preset list.
         * @param presetInfos The new, updated list of presets.
         */
        fun onPresetInfoChangedFromRemote(
            device: BluetoothDevice,
            presetInfos: List<BluetoothHapPresetInfo>
        )

        /**
         * Called when a command to select a preset for a group of devices has failed.
         *
         * @param hapGroupId The HAP group ID for which the command failed.
         */
        fun onPresetGroupSelectionFailedFromRemote(hapGroupId: Int)

        /**
         * Called when a generic command sent to a remote device has failed.
         */
        fun onCommandFailedFromRemote()
    }

    /**
     * A wrapper callback that will pass [BluetoothHapClient.Callback] to
     * [PresetControlCallback].
     */
    private class PresetCallback(private val callback: PresetControlCallback) :
        BluetoothHapClient.Callback {

        override fun onPresetInfoChanged(
            device: BluetoothDevice,
            presetInfos: List<BluetoothHapPresetInfo>,
            reason: Int
        ) {
            if (DEBUG) {
                Log.d(TAG, "onPresetInfoChanged, device=$device, reason=$reason")
                for (info in presetInfos) {
                    Log.d(TAG, "    preset ${info.index}=${info.name}")
                }
            }
            callback.onPresetInfoChangedFromRemote(device, presetInfos)
        }

        override fun onPresetSelected(device: BluetoothDevice, presetIndex: Int, reason: Int) {
            if (DEBUG) {
                Log.d(
                    TAG,
                    "onPresetSelected, device=$device, presetIndex=$presetIndex, reason=$reason"
                )
            }
            callback.onPresetChangedFromRemote(device, presetIndex)
        }

        override fun onPresetSelectionFailed(device: BluetoothDevice, reason: Int) {
            Log.w(TAG, "onPresetSelectionFailed, device=$device")
            callback.onCommandFailedFromRemote()
        }

        override fun onPresetSelectionForGroupFailed(hapGroupId: Int, reason: Int) {
            Log.w(TAG, "onPresetSelectionForGroupFailed, hapGroupId=$hapGroupId")
            callback.onPresetGroupSelectionFailedFromRemote(hapGroupId)
        }

        override fun onSetPresetNameFailed(device: BluetoothDevice, reason: Int) {
            Log.w(TAG, "onSetPresetNameFailed, device=$device")
            callback.onCommandFailedFromRemote()
        }

        override fun onSetPresetNameForGroupFailed(hapGroupId: Int, reason: Int) {
            Log.w(TAG, "onSetPresetNameForGroupFailed, hapGroupId=$hapGroupId")
            callback.onCommandFailedFromRemote()
        }
    }

    companion object {
        private const val DEBUG = true
        private const val TAG = "PresetController"
    }
}
 No newline at end of file
+154 −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

import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothHapPresetInfo
import android.bluetooth.BluetoothProfile
import com.android.settingslib.bluetooth.HapClientProfile
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager
import com.android.settingslib.utils.ThreadUtils
import com.google.common.truth.Truth
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
import org.robolectric.RobolectricTestRunner
import java.util.concurrent.Executor

/** Tests for [PresetController]. */
@RunWith(RobolectricTestRunner::class)
class PresetControllerTest {

    private val profileManager = mock<LocalBluetoothProfileManager>()
    private val hapClientProfile = mock<HapClientProfile> {
        on { isProfileReady } doReturn true
    }
    private val callback = mock<PresetController.PresetControlCallback>()
    private val device = mock<BluetoothDevice> {
        on { isConnected } doReturn true
    }

    private lateinit var presetController: PresetController

    @Before
    fun setUp() {
        profileManager.stub {
            on { hapClientProfile } doReturn hapClientProfile
        }
        hapClientProfile.stub {
            on { getConnectionStatus(device) } doReturn BluetoothProfile.STATE_CONNECTED
        }
        presetController = PresetController(profileManager, callback)
    }

    @Test
    fun onServiceConnected_notifyCallback() {
        presetController.onServiceConnected()

        verify(callback).onHapClientServiceConnected()
    }

    @Test
    fun registerAndUnregisterCallback_verifyRegisterAndUnregisterOnProfile() {
        val executor: Executor = ThreadUtils.getBackgroundExecutor()
        presetController.registerCallback(executor)

        verify(hapClientProfile).registerCallback(eq(executor), any())

        presetController.unregisterCallback()

        verify(hapClientProfile).unregisterCallback(any())
    }

    @Test
    fun getActivePreset_verifyGetThroughProfile() {
        presetController.getActivePreset(device)

        verify(hapClientProfile).getActivePresetIndex(device)
    }

    @Test
    fun getPresetInfos_verifyGetOnlyAvailableInfoThroughProfile() {
        val testPresetInfos = listOf(
            getTestPresetInfo(true),
            getTestPresetInfo(true),
            getTestPresetInfo(false)
        )
        hapClientProfile.stub {
            on { getAllPresetInfo(device) } doReturn testPresetInfos
        }

        val infos: List<BluetoothHapPresetInfo> = presetController.getPresetInfos(device)

        verify(hapClientProfile).getAllPresetInfo(device)
        Truth.assertThat(infos.size).isEqualTo(2)
    }

    @Test
    fun selectPreset_verifySelectThroughProfile() {
        presetController.selectPreset(device, TEST_PRESET_INDEX)

        verify(hapClientProfile).selectPreset(device, TEST_PRESET_INDEX)
    }

    @Test
    fun selectPresetForGroup_verifySelectThroughProfile() {
        presetController.selectPresetForGroup(TEST_HAP_GROUP_ID, TEST_PRESET_INDEX)

        verify(hapClientProfile).selectPresetForGroup(TEST_HAP_GROUP_ID, TEST_PRESET_INDEX)
    }

    @Test
    fun getHapGroupId_verifyGetThroughProfile() {
        hapClientProfile.stub {
            on { getHapGroup(device) } doReturn TEST_HAP_GROUP_ID
        }

        val hapGroupId: Int = presetController.getHapGroupId(device)

        verify(hapClientProfile).getHapGroup(device)
        assertThat(hapGroupId).isEqualTo(TEST_HAP_GROUP_ID)
    }

    @Test
    fun supportsSynchronizedPresets_verifyGetThroughProfile() {
        presetController.supportsSynchronizedPresets(device)

        verify(hapClientProfile).supportsSynchronizedPresets(device)
    }

    private fun getTestPresetInfo(available: Boolean): BluetoothHapPresetInfo {
        return mock<BluetoothHapPresetInfo>() {
            on { name } doReturn TEST_PRESET_NAME
            on { index } doReturn TEST_PRESET_INDEX
            on { isAvailable } doReturn available
        }
    }

    companion object {
        private const val TEST_PRESET_INDEX = 1
        private const val TEST_PRESET_NAME = "test_preset"
        private const val TEST_HAP_GROUP_ID = 1
    }
}
 No newline at end of file