[Pair hearing devices] Add "Hearing devices" to show connected hearing devices

Bug: 237625815
Test: make RunSettingsRoboTests ROBOTEST_FILTER=AvailableHearingDeviceUpdaterTest
Change-Id: I15bff230cac29fdbad13d452878bc57b57d9773e
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 3dfdc8a..ad68657 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -4541,6 +4541,8 @@
     <!-- Title for the hearing device pairing preference. [CHAR LIMIT=20] -->
     <string name="accessibility_hearing_device_pairing_title">Pair new device</string>
     <!-- Title for the preference category containing the connected hearing device group. [CHAR LIMIT=20]-->
+    <string name="accessibility_hearing_device_connected_title">Hearing devices</string>
+    <!-- Title for the preference category containing the previously connected hearing device group. [CHAR LIMIT=20]-->
     <string name="accessibility_hearing_device_saved_title">Saved devices</string>
     <!-- Title for the preference category containing the controls of the hearing device. [CHAR LIMIT=35] -->
     <string name="accessibility_hearing_device_control">Hearing device controls</string>
diff --git a/res/xml/accessibility_hearing_aids.xml b/res/xml/accessibility_hearing_aids.xml
index 5d6cff1..4d4028a 100644
--- a/res/xml/accessibility_hearing_aids.xml
+++ b/res/xml/accessibility_hearing_aids.xml
@@ -19,6 +19,11 @@
     android:key="accessibility_hearing_devices_screen"
     android:title="@string/accessibility_hearingaid_title">
 
+    <PreferenceCategory
+        android:key="available_hearing_devices"
+        android:title="@string/accessibility_hearing_device_connected_title"
+        settings:controller="com.android.settings.accessibility.AvailableHearingDevicePreferenceController"/>
+
     <com.android.settingslib.RestrictedPreference
         android:key="add_bt_devices"
         android:title="@string/bluetooth_pairing_pref_title"
diff --git a/src/com/android/settings/accessibility/AccessibilityHearingAidPreferenceController.java b/src/com/android/settings/accessibility/AccessibilityHearingAidPreferenceController.java
index cec48bb..cd76b47 100644
--- a/src/com/android/settings/accessibility/AccessibilityHearingAidPreferenceController.java
+++ b/src/com/android/settings/accessibility/AccessibilityHearingAidPreferenceController.java
@@ -29,7 +29,6 @@
 import android.os.Bundle;
 import android.text.TextUtils;
 import android.util.FeatureFlagUtils;
-import android.util.Log;
 
 import androidx.annotation.VisibleForTesting;
 import androidx.fragment.app.FragmentManager;
@@ -55,8 +54,6 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.FutureTask;
 import java.util.stream.Collectors;
 
 /**
@@ -84,7 +81,8 @@
 
     public AccessibilityHearingAidPreferenceController(Context context, String preferenceKey) {
         super(context, preferenceKey);
-        mLocalBluetoothManager = getLocalBluetoothManager();
+        mLocalBluetoothManager = com.android.settings.bluetooth.Utils.getLocalBluetoothManager(
+                context);
         mProfileManager = mLocalBluetoothManager.getProfileManager();
         mCachedDeviceManager = mLocalBluetoothManager.getCachedDeviceManager();
         mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
@@ -269,19 +267,6 @@
         return false;
     }
 
-    private LocalBluetoothManager getLocalBluetoothManager() {
-        final FutureTask<LocalBluetoothManager> localBtManagerFutureTask = new FutureTask<>(
-                // Avoid StrictMode ThreadPolicy violation
-                () -> com.android.settings.bluetooth.Utils.getLocalBtManager(mContext));
-        try {
-            localBtManagerFutureTask.run();
-            return localBtManagerFutureTask.get();
-        } catch (InterruptedException | ExecutionException e) {
-            Log.w(TAG, "Error getting LocalBluetoothManager.", e);
-            return null;
-        }
-    }
-
     @VisibleForTesting(otherwise = VisibleForTesting.NONE)
     void setPreference(Preference preference) {
         mHearingAidPreference = preference;
diff --git a/src/com/android/settings/accessibility/AccessibilityHearingAidsFragment.java b/src/com/android/settings/accessibility/AccessibilityHearingAidsFragment.java
index 519b751..85783b73 100644
--- a/src/com/android/settings/accessibility/AccessibilityHearingAidsFragment.java
+++ b/src/com/android/settings/accessibility/AccessibilityHearingAidsFragment.java
@@ -48,6 +48,7 @@
     @Override
     public void onAttach(Context context) {
         super.onAttach(context);
+        use(AvailableHearingDevicePreferenceController.class).init(this);
         use(SavedHearingDevicePreferenceController.class).init(this);
     }
 
diff --git a/src/com/android/settings/accessibility/AvailableHearingDevicePreferenceController.java b/src/com/android/settings/accessibility/AvailableHearingDevicePreferenceController.java
new file mode 100644
index 0000000..076432c
--- /dev/null
+++ b/src/com/android/settings/accessibility/AvailableHearingDevicePreferenceController.java
@@ -0,0 +1,109 @@
+/*
+ * 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.settings.accessibility;
+
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+
+import androidx.fragment.app.FragmentManager;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.bluetooth.BluetoothDeviceUpdater;
+import com.android.settings.connecteddevice.DevicePreferenceCallback;
+import com.android.settings.dashboard.DashboardFragment;
+import com.android.settingslib.bluetooth.BluetoothCallback;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.core.lifecycle.LifecycleObserver;
+import com.android.settingslib.core.lifecycle.events.OnStart;
+import com.android.settingslib.core.lifecycle.events.OnStop;
+
+/**
+ * Controller to update the {@link androidx.preference.PreferenceCategory} for all
+ * connected hearing devices, including ASHA and HAP profile.
+ * Parent class {@link BaseBluetoothDevicePreferenceController} will use
+ * {@link DevicePreferenceCallback} to add/remove {@link Preference}.
+ */
+public class AvailableHearingDevicePreferenceController extends
+        BaseBluetoothDevicePreferenceController implements LifecycleObserver, OnStart, OnStop,
+        BluetoothCallback {
+
+    private static final String TAG = "AvailableHearingDevicePreferenceController";
+
+    private BluetoothDeviceUpdater mAvailableHearingDeviceUpdater;
+    private final LocalBluetoothManager mLocalBluetoothManager;
+    private FragmentManager mFragmentManager;
+
+    public AvailableHearingDevicePreferenceController(Context context,
+            String preferenceKey) {
+        super(context, preferenceKey);
+        mLocalBluetoothManager = com.android.settings.bluetooth.Utils.getLocalBluetoothManager(
+                context);
+    }
+
+    /**
+     * Initializes objects in this controller. Need to call this before onStart().
+     *
+     * <p>Should not call this more than 1 time.
+     *
+     * @param fragment The {@link DashboardFragment} uses the controller.
+     */
+    public void init(DashboardFragment fragment) {
+        if (mAvailableHearingDeviceUpdater != null) {
+            throw new IllegalStateException("Should not call init() more than 1 time.");
+        }
+        mAvailableHearingDeviceUpdater = new AvailableHearingDeviceUpdater(fragment.getContext(),
+                this, fragment.getMetricsCategory());
+        mFragmentManager = fragment.getParentFragmentManager();
+    }
+
+    @Override
+    public void onStart() {
+        mAvailableHearingDeviceUpdater.registerCallback();
+        mAvailableHearingDeviceUpdater.refreshPreference();
+        mLocalBluetoothManager.getEventManager().registerCallback(this);
+    }
+
+    @Override
+    public void onStop() {
+        mAvailableHearingDeviceUpdater.unregisterCallback();
+        mLocalBluetoothManager.getEventManager().unregisterCallback(this);
+    }
+
+    @Override
+    public void displayPreference(PreferenceScreen screen) {
+        super.displayPreference(screen);
+
+        if (isAvailable()) {
+            final Context context = screen.getContext();
+            mAvailableHearingDeviceUpdater.setPrefContext(context);
+            mAvailableHearingDeviceUpdater.forceUpdate();
+        }
+    }
+
+    @Override
+    public void onActiveDeviceChanged(CachedBluetoothDevice activeDevice, int bluetoothProfile) {
+        if (activeDevice == null) {
+            return;
+        }
+
+        if (bluetoothProfile == BluetoothProfile.HEARING_AID) {
+            HearingAidUtils.launchHearingAidPairingDialog(mFragmentManager, activeDevice);
+        }
+    }
+}
diff --git a/src/com/android/settings/accessibility/AvailableHearingDeviceUpdater.java b/src/com/android/settings/accessibility/AvailableHearingDeviceUpdater.java
new file mode 100644
index 0000000..b3d3715
--- /dev/null
+++ b/src/com/android/settings/accessibility/AvailableHearingDeviceUpdater.java
@@ -0,0 +1,51 @@
+/*
+ * 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.settings.accessibility;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+
+import com.android.settings.bluetooth.AvailableMediaBluetoothDeviceUpdater;
+import com.android.settings.connecteddevice.DevicePreferenceCallback;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+
+/**
+ * Maintains and updates connected hearing devices, including ASHA and HAP profile.
+ */
+public class AvailableHearingDeviceUpdater extends AvailableMediaBluetoothDeviceUpdater {
+
+    private static final String PREF_KEY = "connected_hearing_device";
+
+    public AvailableHearingDeviceUpdater(Context context,
+            DevicePreferenceCallback devicePreferenceCallback, int metricsCategory) {
+        super(context, devicePreferenceCallback, metricsCategory);
+    }
+
+    @Override
+    public boolean isFilterMatched(CachedBluetoothDevice cachedDevice) {
+        final BluetoothDevice device = cachedDevice.getDevice();
+        final boolean isConnectedHearingAidDevice = (cachedDevice.isConnectedHearingAidDevice()
+                && (device.getBondState() == BluetoothDevice.BOND_BONDED));
+
+        return isConnectedHearingAidDevice && isDeviceInCachedDevicesList(cachedDevice);
+    }
+
+    @Override
+    protected String getPreferenceKey() {
+        return PREF_KEY;
+    }
+}
diff --git a/src/com/android/settings/bluetooth/Utils.java b/src/com/android/settings/bluetooth/Utils.java
index 9aa363a..5abc72b 100644
--- a/src/com/android/settings/bluetooth/Utils.java
+++ b/src/com/android/settings/bluetooth/Utils.java
@@ -46,6 +46,9 @@
 import com.android.settingslib.bluetooth.LocalBluetoothManager;
 import com.android.settingslib.bluetooth.LocalBluetoothManager.BluetoothManagerCallback;
 
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+
 /**
  * Utils is a helper class that contains constants for various
  * Android resource IDs, debug logging flags, and static methods
@@ -136,6 +139,24 @@
         return LocalBluetoothManager.getInstance(context, mOnInitCallback);
     }
 
+    /**
+     * Obtains a {@link LocalBluetoothManager}.
+     *
+     * To avoid StrictMode ThreadPolicy violation, will get it in another thread.
+     */
+    public static LocalBluetoothManager getLocalBluetoothManager(Context context) {
+        final FutureTask<LocalBluetoothManager> localBtManagerFutureTask = new FutureTask<>(
+                // Avoid StrictMode ThreadPolicy violation
+                () -> getLocalBtManager(context));
+        try {
+            localBtManagerFutureTask.run();
+            return localBtManagerFutureTask.get();
+        } catch (InterruptedException | ExecutionException e) {
+            Log.w(TAG, "Error getting LocalBluetoothManager.", e);
+            return null;
+        }
+    }
+
     public static String createRemoteName(Context context, BluetoothDevice device) {
         String mRemoteName = device != null ? device.getAlias() : null;
 
diff --git a/tests/robotests/src/com/android/settings/accessibility/AvailableHearingDeviceUpdaterTest.java b/tests/robotests/src/com/android/settings/accessibility/AvailableHearingDeviceUpdaterTest.java
new file mode 100644
index 0000000..6305014
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/accessibility/AvailableHearingDeviceUpdaterTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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.settings.accessibility;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.when;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.DevicePreferenceCallback;
+import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+
+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 org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Tests for {@link AvailableHearingDeviceUpdater}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(shadows = {ShadowBluetoothUtils.class})
+public class AvailableHearingDeviceUpdaterTest {
+    @Rule
+    public MockitoRule mMockitoRule = MockitoJUnit.rule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+
+    @Mock
+    private DevicePreferenceCallback mDevicePreferenceCallback;
+    @Mock
+    private CachedBluetoothDeviceManager mCachedDeviceManager;
+    @Mock
+    private LocalBluetoothManager mLocalBluetoothManager;
+    @Mock
+    private CachedBluetoothDevice mCachedBluetoothDevice;
+    @Mock
+    private BluetoothDevice mBluetoothDevice;
+    private AvailableHearingDeviceUpdater mUpdater;
+
+    @Before
+    public void setUp() {
+        ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBluetoothManager;
+        mLocalBluetoothManager = Utils.getLocalBtManager(mContext);
+        when(mLocalBluetoothManager.getCachedDeviceManager()).thenReturn(mCachedDeviceManager);
+        when(mCachedBluetoothDevice.getDevice()).thenReturn(mBluetoothDevice);
+        mUpdater = new AvailableHearingDeviceUpdater(mContext,
+                mDevicePreferenceCallback, /* metricsCategory= */ 0);
+    }
+
+    @Test
+    public void isFilterMatch_connectedHearingDevice_returnTrue() {
+        CachedBluetoothDevice connectedHearingDevice = mCachedBluetoothDevice;
+        when(connectedHearingDevice.isConnectedHearingAidDevice()).thenReturn(true);
+        doReturn(BluetoothDevice.BOND_BONDED).when(mBluetoothDevice).getBondState();
+        when(mCachedDeviceManager.getCachedDevicesCopy()).thenReturn(
+                new ArrayList<>(List.of(connectedHearingDevice)));
+
+        assertThat(mUpdater.isFilterMatched(connectedHearingDevice)).isEqualTo(true);
+    }
+
+    @Test
+    public void isFilterMatch_nonConnectedHearingDevice_returnFalse() {
+        CachedBluetoothDevice nonConnectedHearingDevice = mCachedBluetoothDevice;
+        when(nonConnectedHearingDevice.isConnectedHearingAidDevice()).thenReturn(false);
+        doReturn(BluetoothDevice.BOND_BONDED).when(mBluetoothDevice).getBondState();
+        when(mCachedDeviceManager.getCachedDevicesCopy()).thenReturn(
+                new ArrayList<>(List.of(nonConnectedHearingDevice)));
+
+        assertThat(mUpdater.isFilterMatched(nonConnectedHearingDevice)).isEqualTo(false);
+    }
+
+    @Test
+    public void isFilterMatch_connectedBondingHearingDevice_returnFalse() {
+        CachedBluetoothDevice connectedBondingHearingDevice = mCachedBluetoothDevice;
+        when(connectedBondingHearingDevice.isHearingAidDevice()).thenReturn(true);
+        doReturn(BluetoothDevice.BOND_BONDING).when(mBluetoothDevice).getBondState();
+        when(mCachedDeviceManager.getCachedDevicesCopy()).thenReturn(
+                new ArrayList<>(List.of(connectedBondingHearingDevice)));
+
+        assertThat(mUpdater.isFilterMatched(connectedBondingHearingDevice)).isEqualTo(false);
+    }
+
+    @Test
+    public void isFilterMatch_hearingDeviceNotInCachedDevicesList_returnFalse() {
+        CachedBluetoothDevice notInCachedDevicesListDevice = mCachedBluetoothDevice;
+        when(notInCachedDevicesListDevice.isHearingAidDevice()).thenReturn(true);
+        doReturn(BluetoothDevice.BOND_BONDED).when(mBluetoothDevice).getBondState();
+        doReturn(false).when(mBluetoothDevice).isConnected();
+        when(mCachedDeviceManager.getCachedDevicesCopy()).thenReturn(new ArrayList<>());
+
+        assertThat(mUpdater.isFilterMatched(notInCachedDevicesListDevice)).isEqualTo(false);
+    }
+}