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

Commit f2d7b9fd authored by Jason Hsu's avatar Jason Hsu Committed by Android (Google) Code Review
Browse files

Merge "Add Hearing Devices tile status" into main

parents 353628a7 618e3a3d
Loading
Loading
Loading
Loading
+108 −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.systemui.accessibility.hearingaid;

import android.bluetooth.BluetoothDevice;
import android.content.Context;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;

import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.systemui.dagger.SysUISingleton;

import javax.inject.Inject;

/**
 * HearingDevicesChecker provides utility methods to determine the presence and status of
 * connected hearing aid devices.
 *
 * <p>It also filters out devices that are exclusively managed by other applications to avoid
 * interfering with their operation.
 */
@SysUISingleton
public class HearingDevicesChecker {

    private final Context mContext;
    private final LocalBluetoothManager mLocalBluetoothManager;

    @Inject
    public HearingDevicesChecker(
            Context context,
            @Nullable LocalBluetoothManager localBluetoothManager) {
        mContext = context;
        mLocalBluetoothManager = localBluetoothManager;
    }

    /**
     * Checks if any hearing device is already paired.
     *
     * <p>It includes {@link BluetoothDevice.BOND_BONDING} and {@link BluetoothDevice.BOND_BONDED}).
     *
     * <p>A bonded device means it has been paired, but may not connected now.
     *
     * @return {@code true} if any bonded hearing device is found, {@code false} otherwise.
     */
    @WorkerThread
    public boolean isAnyPairedHearingDevice() {
        if (mLocalBluetoothManager == null) {
            return false;
        }
        if (!mLocalBluetoothManager.getBluetoothAdapter().isEnabled()) {
            return false;
        }

        return mLocalBluetoothManager.getCachedDeviceManager().getCachedDevicesCopy().stream()
                .anyMatch(device -> device.isHearingAidDevice()
                        && device.getBondState() != BluetoothDevice.BOND_NONE
                        && !isExclusivelyManagedBluetoothDevice(device));
    }

    /**
     * Checks if there are any active hearing device.
     *
     * <p>An active device means it is currently connected and streaming media.
     *
     * @return {@code true} if any active hearing device is found, {@code false} otherwise.
     */
    @WorkerThread
    public boolean isAnyActiveHearingDevice() {
        if (mLocalBluetoothManager == null) {
            return false;
        }
        if (!mLocalBluetoothManager.getBluetoothAdapter().isEnabled()) {
            return false;
        }

        return mLocalBluetoothManager.getCachedDeviceManager().getCachedDevicesCopy().stream()
                .anyMatch(device -> BluetoothUtils.isActiveMediaDevice(device)
                        && BluetoothUtils.isAvailableHearingDevice(device)
                        && !isExclusivelyManagedBluetoothDevice(device));
    }

    private boolean isExclusivelyManagedBluetoothDevice(
            @NonNull CachedBluetoothDevice cachedDevice) {
        if (com.android.settingslib.flags.Flags.enableHideExclusivelyManagedBluetoothDevice()) {
            return BluetoothUtils.isExclusivelyManagedBluetoothDevice(mContext,
                    cachedDevice.getDevice());
        }
        return false;
    }
}
+46 −30
Original line number Diff line number Diff line
@@ -16,19 +16,24 @@

package com.android.systemui.accessibility.hearingaid;

import android.bluetooth.BluetoothDevice;
import android.util.Log;

import androidx.annotation.Nullable;
import androidx.concurrent.futures.CallbackToFutureAdapter;

import com.android.internal.jank.InteractionJankMonitor;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.systemui.animation.DialogCuj;
import com.android.systemui.animation.DialogTransitionAnimator;
import com.android.systemui.animation.Expandable;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.statusbar.phone.SystemUIDialog;

import com.google.common.util.concurrent.ListenableFuture;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;

import javax.inject.Inject;

/**
@@ -43,16 +48,22 @@ public class HearingDevicesDialogManager {
    private SystemUIDialog mDialog;
    private final DialogTransitionAnimator mDialogTransitionAnimator;
    private final HearingDevicesDialogDelegate.Factory mDialogFactory;
    private final LocalBluetoothManager mLocalBluetoothManager;
    private final HearingDevicesChecker mDevicesChecker;
    private final Executor mBackgroundExecutor;
    private final Executor mMainExecutor;

    @Inject
    public HearingDevicesDialogManager(
            DialogTransitionAnimator dialogTransitionAnimator,
            HearingDevicesDialogDelegate.Factory dialogFactory,
            @Nullable LocalBluetoothManager localBluetoothManager) {
            HearingDevicesChecker devicesChecker,
            @Background Executor backgroundExecutor,
            @Main Executor mainExecutor) {
        mDialogTransitionAnimator = dialogTransitionAnimator;
        mDialogFactory = dialogFactory;
        mLocalBluetoothManager = localBluetoothManager;
        mDevicesChecker = devicesChecker;
        mBackgroundExecutor = backgroundExecutor;
        mMainExecutor = mainExecutor;
    }

    /**
@@ -68,10 +79,23 @@ public class HearingDevicesDialogManager {
            destroyDialog();
        }

        mDialog = mDialogFactory.create(!isAnyBondedHearingDevice()).createDialog();
        final ListenableFuture<Boolean> pairedHearingDeviceCheckTask =
                CallbackToFutureAdapter.getFuture(completer -> {
                    mBackgroundExecutor.execute(
                            () -> {
                                completer.set(mDevicesChecker.isAnyPairedHearingDevice());
                            });
                    // This value is used only for debug purposes: it will be used in toString()
                    // of returned future or error cases.
                    return "isAnyPairedHearingDevice check";
                });
        pairedHearingDeviceCheckTask.addListener(() -> {
            try {
                mDialog = mDialogFactory.create(!pairedHearingDeviceCheckTask.get()).createDialog();

                if (expandable != null) {
            DialogTransitionAnimator.Controller controller = expandable.dialogTransitionController(
                    DialogTransitionAnimator.Controller controller =
                            expandable.dialogTransitionController(
                                    new DialogCuj(InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
                                            INTERACTION_JANK_TAG));
                    if (controller != null) {
@@ -81,23 +105,15 @@ public class HearingDevicesDialogManager {
                    }
                }
                mDialog.show();

            } catch (InterruptedException | ExecutionException e) {
                Log.e(TAG, "Exception occurs while running pairedHearingDeviceCheckTask", e);
            }
        }, mMainExecutor);
    }

    private void destroyDialog() {
        mDialog.dismiss();
        mDialog = null;
    }

    private boolean isAnyBondedHearingDevice() {
        if (mLocalBluetoothManager == null) {
            return false;
        }
        if (!mLocalBluetoothManager.getBluetoothAdapter().isEnabled()) {
            return false;
        }

        return mLocalBluetoothManager.getCachedDeviceManager().getCachedDevicesCopy().stream()
                .anyMatch(device -> device.isHearingAidDevice()
                        && device.getBondState() != BluetoothDevice.BOND_NONE);
    }
}
+50 −8
Original line number Diff line number Diff line
@@ -19,34 +19,53 @@ package com.android.systemui.qs.tiles;
import android.content.Intent;
import android.os.Handler;
import android.os.Looper;
import android.os.UserManager;
import android.provider.Settings;
import android.service.quicksettings.Tile;

import androidx.annotation.Nullable;

import com.android.internal.logging.MetricsLogger;
import com.android.systemui.Flags;
import com.android.systemui.accessibility.hearingaid.HearingDevicesChecker;
import com.android.systemui.accessibility.hearingaid.HearingDevicesDialogManager;
import com.android.systemui.animation.Expandable;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.plugins.FalsingManager;
import com.android.systemui.plugins.qs.QSTile.State;
import com.android.systemui.plugins.qs.QSTile.BooleanState;
import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.qs.QSHost;
import com.android.systemui.qs.QsEventLogger;
import com.android.systemui.qs.logging.QSLogger;
import com.android.systemui.qs.tileimpl.QSTileImpl;
import com.android.systemui.res.R;
import com.android.systemui.statusbar.policy.BluetoothController;

import javax.inject.Inject;

/** Quick settings tile: Hearing Devices **/
public class HearingDevicesTile extends QSTileImpl<State> {

public class HearingDevicesTile extends QSTileImpl<BooleanState> {
    //TODO(b/338520598): Transform the current implementation into new QS architecture
    // and use Kotlin except Tile class.
    public static final String TILE_SPEC = "hearing_devices";

    private final HearingDevicesDialogManager mDialogManager;
    private final HearingDevicesChecker mDevicesChecker;
    private final BluetoothController mBluetoothController;

    private final BluetoothController.Callback mCallback = new BluetoothController.Callback() {
        @Override
        public void onBluetoothStateChange(boolean enabled) {
            refreshState();
        }

        @Override
        public void onBluetoothDevicesChanged() {
            refreshState();
        }
    };

    @Inject
    public HearingDevicesTile(
@@ -59,16 +78,20 @@ public class HearingDevicesTile extends QSTileImpl<State> {
            StatusBarStateController statusBarStateController,
            ActivityStarter activityStarter,
            QSLogger qsLogger,
            HearingDevicesDialogManager hearingDevicesDialogManager
    ) {
            HearingDevicesDialogManager hearingDevicesDialogManager,
            HearingDevicesChecker hearingDevicesChecker,
            BluetoothController bluetoothController) {
        super(host, uiEventLogger, backgroundLooper, mainHandler, falsingManager, metricsLogger,
                statusBarStateController, activityStarter, qsLogger);
        mDialogManager = hearingDevicesDialogManager;
        mDevicesChecker = hearingDevicesChecker;
        mBluetoothController = bluetoothController;
        mBluetoothController.observe(getLifecycle(), mCallback);
    }

    @Override
    public State newTileState() {
        return new State();
    public BooleanState newTileState() {
        return new BooleanState();
    }

    @Override
@@ -77,9 +100,28 @@ public class HearingDevicesTile extends QSTileImpl<State> {
    }

    @Override
    protected void handleUpdateState(State state, Object arg) {
    protected void handleUpdateState(BooleanState state, Object arg) {
        checkIfRestrictionEnforcedByAdminOnly(state, UserManager.DISALLOW_BLUETOOTH);

        state.label = mContext.getString(R.string.quick_settings_hearing_devices_label);
        state.icon = ResourceIcon.get(R.drawable.qs_hearing_devices_icon);
        state.forceExpandIcon = true;

        boolean isBonded = mDevicesChecker.isAnyPairedHearingDevice();
        boolean isActive = mDevicesChecker.isAnyActiveHearingDevice();

        if (isActive) {
            state.state = Tile.STATE_ACTIVE;
            state.secondaryLabel = mContext.getString(
                    R.string.quick_settings_hearing_devices_connected);
        } else if (isBonded) {
            state.state = Tile.STATE_INACTIVE;
            state.secondaryLabel = mContext.getString(
                    R.string.quick_settings_hearing_devices_disconnected);
        } else {
            state.state = Tile.STATE_INACTIVE;
            state.secondaryLabel = "";
        }
    }

    @Nullable
+116 −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.systemui.accessibility.hearingaid;

import static com.google.common.truth.Truth.assertThat;

import static org.mockito.Mockito.when;

import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothProfile;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;

import androidx.test.filters.SmallTest;

import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
import com.android.settingslib.bluetooth.LocalBluetoothAdapter;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.systemui.SysuiTestCase;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;

import java.util.ArrayList;
import java.util.List;

@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
@SmallTest
public class HearingDevicesCheckerTest extends SysuiTestCase {
    @Rule
    public MockitoRule mockito = MockitoJUnit.rule();

    private final List<CachedBluetoothDevice> mCachedDevices = new ArrayList<>();
    @Mock
    private LocalBluetoothManager mLocalBluetoothManager;
    @Mock
    private LocalBluetoothAdapter mLocalBluetoothAdapter;
    @Mock
    private CachedBluetoothDeviceManager mCachedBluetoothDeviceManager;
    @Mock
    private CachedBluetoothDevice mCachedDevice;
    @Mock
    private BluetoothDevice mDevice;
    private HearingDevicesChecker mDevicesChecker;

    @Before
    public void setUp() {
        when(mLocalBluetoothManager.getBluetoothAdapter()).thenReturn(mLocalBluetoothAdapter);
        when(mLocalBluetoothManager.getCachedDeviceManager()).thenReturn(
                mCachedBluetoothDeviceManager);
        when(mCachedBluetoothDeviceManager.getCachedDevicesCopy()).thenReturn(mCachedDevices);
        when(mCachedDevice.getDevice()).thenReturn(mDevice);
        when(mDevice.getMetadata(BluetoothDevice.METADATA_EXCLUSIVE_MANAGER)).thenReturn(
                null);

        mDevicesChecker = new HearingDevicesChecker(mContext, mLocalBluetoothManager);
    }

    @Test
    public void isAnyPairedHearingDevice_bluetoothDisable_returnFalse() {
        when(mLocalBluetoothAdapter.isEnabled()).thenReturn(false);

        assertThat(mDevicesChecker.isAnyPairedHearingDevice()).isFalse();
    }

    @Test
    public void isAnyActiveHearingDevice_bluetoothDisable_returnFalse() {
        when(mLocalBluetoothAdapter.isEnabled()).thenReturn(false);

        assertThat(mDevicesChecker.isAnyActiveHearingDevice()).isFalse();
    }

    @Test
    public void isAnyPairedHearingDevice_hearingAidBonded_returnTrue() {
        when(mLocalBluetoothAdapter.isEnabled()).thenReturn(true);
        when(mCachedDevice.isHearingAidDevice()).thenReturn(true);
        when(mCachedDevice.getBondState()).thenReturn(BluetoothDevice.BOND_BONDED);
        mCachedDevices.add(mCachedDevice);

        assertThat(mDevicesChecker.isAnyPairedHearingDevice()).isTrue();
    }

    @Test
    public void isAnyActiveHearingDevice_hearingAidActiveAndConnected_returnTrue() {
        when(mLocalBluetoothAdapter.isEnabled()).thenReturn(true);
        when(mCachedDevice.isActiveDevice(BluetoothProfile.HEARING_AID)).thenReturn(true);
        when(mCachedDevice.getBondState()).thenReturn(BluetoothDevice.BOND_BONDED);
        when(mDevice.getBondState()).thenReturn(BluetoothDevice.BOND_BONDED);
        when(mDevice.isConnected()).thenReturn(true);
        when(mCachedDevice.isConnectedHearingAidDevice()).thenReturn(true);
        mCachedDevices.add(mCachedDevice);

        assertThat(mDevicesChecker.isAnyActiveHearingDevice()).isTrue();
    }
}
+18 −30
Original line number Diff line number Diff line
@@ -21,20 +21,17 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.bluetooth.BluetoothDevice;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;

import androidx.test.filters.SmallTest;

import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
import com.android.settingslib.bluetooth.LocalBluetoothAdapter;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.systemui.SysuiTestCase;
import com.android.systemui.animation.DialogTransitionAnimator;
import com.android.systemui.animation.Expandable;
import com.android.systemui.statusbar.phone.SystemUIDialog;
import com.android.systemui.util.concurrency.FakeExecutor;
import com.android.systemui.util.time.FakeSystemClock;

import org.junit.Before;
import org.junit.Rule;
@@ -44,9 +41,6 @@ import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;

import java.util.ArrayList;
import java.util.List;

/** Tests for {@link HearingDevicesDialogManager}. */
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
@@ -56,7 +50,8 @@ public class HearingDevicesDialogManagerTest extends SysuiTestCase {
    @Rule
    public MockitoRule mockito = MockitoJUnit.rule();

    private final List<CachedBluetoothDevice> mCachedDevices = new ArrayList<>();
    private final FakeExecutor mMainExecutor = new FakeExecutor(new FakeSystemClock());
    private final FakeExecutor mBackgroundExecutor = new FakeExecutor(new FakeSystemClock());
    @Mock
    private Expandable mExpandable;
    @Mock
@@ -68,13 +63,7 @@ public class HearingDevicesDialogManagerTest extends SysuiTestCase {
    @Mock
    private SystemUIDialog mDialog;
    @Mock
    private LocalBluetoothManager mLocalBluetoothManager;
    @Mock
    private LocalBluetoothAdapter mLocalBluetoothAdapter;
    @Mock
    private CachedBluetoothDeviceManager mCachedBluetoothDeviceManager;
    @Mock
    private CachedBluetoothDevice mCachedDevice;
    private HearingDevicesChecker mDevicesChecker;

    private HearingDevicesDialogManager mManager;

@@ -82,36 +71,35 @@ public class HearingDevicesDialogManagerTest extends SysuiTestCase {
    public void setUp() {
        when(mDialogFactory.create(anyBoolean())).thenReturn(mDialogDelegate);
        when(mDialogDelegate.createDialog()).thenReturn(mDialog);
        when(mLocalBluetoothManager.getBluetoothAdapter()).thenReturn(mLocalBluetoothAdapter);
        when(mLocalBluetoothManager.getCachedDeviceManager()).thenReturn(
                mCachedBluetoothDeviceManager);
        when(mCachedBluetoothDeviceManager.getCachedDevicesCopy()).thenReturn(mCachedDevices);

        mManager = new HearingDevicesDialogManager(
                mDialogTransitionAnimator,
                mDialogFactory,
                mLocalBluetoothManager
                mDevicesChecker,
                mBackgroundExecutor,
                mMainExecutor
        );
    }

    @Test
    public void showDialog_bluetoothDisable_showPairNewDeviceTrue() {
        when(mLocalBluetoothAdapter.isEnabled()).thenReturn(false);
    public void showDialog_existHearingDevice_showPairNewDeviceFalse() {
        when(mDevicesChecker.isAnyPairedHearingDevice()).thenReturn(true);

        mManager.showDialog(mExpandable);
        mBackgroundExecutor.runAllReady();
        mMainExecutor.runAllReady();

        verify(mDialogFactory).create(eq(true));
        verify(mDialogFactory).create(eq(/* showPairNewDevice= */ false));
    }

    @Test
    public void showDialog_containsHearingAid_showPairNewDeviceFalse() {
        when(mLocalBluetoothAdapter.isEnabled()).thenReturn(true);
        when(mCachedDevice.isHearingAidDevice()).thenReturn(true);
        when(mCachedDevice.getBondState()).thenReturn(BluetoothDevice.BOND_BONDED);
        mCachedDevices.add(mCachedDevice);
    public void showDialog_noHearingDevice_showPairNewDeviceTrue() {
        when(mDevicesChecker.isAnyPairedHearingDevice()).thenReturn(false);

        mManager.showDialog(mExpandable);
        mBackgroundExecutor.runAllReady();
        mMainExecutor.runAllReady();

        verify(mDialogFactory).create(eq(false));
        verify(mDialogFactory).create(eq(/* showPairNewDevice= */ true));
    }
}
Loading