Merge "[Audiosharing] Impl the switch audio sharing dialog." into main
diff --git a/res/layout/dialog_audio_sharing_disconnect.xml b/res/layout/dialog_audio_sharing_disconnect.xml
new file mode 100644
index 0000000..09bac40
--- /dev/null
+++ b/res/layout/dialog_audio_sharing_disconnect.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:padding="24dp"
+    android:orientation="vertical">
+
+    <TextView
+        android:id="@+id/share_audio_disconnect_description"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textAlignment="center"
+        android:layout_gravity="center"/>
+
+    <com.android.internal.widget.RecyclerView
+        android:visibility="visible"
+        android:id="@+id/device_btn_list"
+        android:nestedScrollingEnabled="false"
+        android:overScrollMode="never"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"/>
+
+    <Button
+        android:id="@+id/cancel_btn"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:text="@string/cancel"/>
+</LinearLayout>
\ No newline at end of file
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDevicePreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDevicePreferenceController.java
index 0a6795f..dd5eb62 100644
--- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDevicePreferenceController.java
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDevicePreferenceController.java
@@ -17,7 +17,9 @@
 package com.android.settings.connecteddevice.audiosharing;
 
 import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothCsipSetCoordinator;
 import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcast;
 import android.bluetooth.BluetoothLeBroadcastAssistant;
 import android.bluetooth.BluetoothLeBroadcastMetadata;
 import android.bluetooth.BluetoothLeBroadcastReceiveState;
@@ -41,13 +43,18 @@
 import com.android.settings.flags.Flags;
 import com.android.settingslib.bluetooth.BluetoothCallback;
 import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
 import com.android.settingslib.bluetooth.LeAudioProfile;
 import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
 import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
 import com.android.settingslib.bluetooth.LocalBluetoothManager;
-import com.android.settingslib.bluetooth.LocalBluetoothProfile;
 
+import com.google.common.collect.ImmutableList;
+
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
 
@@ -68,6 +75,37 @@
     private BluetoothDeviceUpdater mBluetoothDeviceUpdater;
     private DashboardFragment mFragment;
 
+    private final BluetoothLeBroadcast.Callback mBroadcastCallback =
+            new BluetoothLeBroadcast.Callback() {
+                @Override
+                public void onBroadcastStarted(int reason, int broadcastId) {}
+
+                @Override
+                public void onBroadcastStartFailed(int reason) {}
+
+                @Override
+                public void onBroadcastMetadataChanged(
+                        int broadcastId, @NonNull BluetoothLeBroadcastMetadata metadata) {}
+
+                @Override
+                public void onBroadcastStopped(int reason, int broadcastId) {}
+
+                @Override
+                public void onBroadcastStopFailed(int reason) {}
+
+                @Override
+                public void onBroadcastUpdated(int reason, int broadcastId) {}
+
+                @Override
+                public void onBroadcastUpdateFailed(int reason, int broadcastId) {}
+
+                @Override
+                public void onPlaybackStarted(int reason, int broadcastId) {}
+
+                @Override
+                public void onPlaybackStopped(int reason, int broadcastId) {}
+            };
+
     private BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
             new BluetoothLeBroadcastAssistant.Callback() {
                 @Override
@@ -169,8 +207,8 @@
             Log.d(TAG, "onStart() Bluetooth is not supported on this device");
             return;
         }
-        if (mAssistant == null) {
-            Log.d(TAG, "onStart() Broadcast assistant is not supported on this device");
+        if (mBroadcast == null || mAssistant == null) {
+            Log.d(TAG, "onStart() Broadcast or assistant is not supported on this device");
             return;
         }
         if (mBluetoothDeviceUpdater == null) {
@@ -178,6 +216,7 @@
             return;
         }
         mLocalBtManager.getEventManager().registerCallback(this);
+        mBroadcast.registerServiceCallBack(mExecutor, mBroadcastCallback);
         mAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
         mBluetoothDeviceUpdater.registerCallback();
         mBluetoothDeviceUpdater.refreshPreference();
@@ -189,8 +228,8 @@
             Log.d(TAG, "onStop() Bluetooth is not supported on this device");
             return;
         }
-        if (mAssistant == null) {
-            Log.d(TAG, "onStop() Broadcast assistant is not supported on this device");
+        if (mBroadcast == null || mAssistant == null) {
+            Log.d(TAG, "onStop() Broadcast or assistant is not supported on this device");
             return;
         }
         if (mBluetoothDeviceUpdater == null) {
@@ -200,9 +239,12 @@
         mLocalBtManager.getEventManager().unregisterCallback(this);
         // TODO: verify the reason for failing to unregister
         try {
+            mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
             mAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
         } catch (IllegalArgumentException e) {
-            Log.e(TAG, "Fail to unregister assistant callback due to " + e.getMessage());
+            Log.e(
+                    TAG,
+                    "Fail to unregister broadcast or assistant callback due to " + e.getMessage());
         }
         mBluetoothDeviceUpdater.unregisterCallback();
     }
@@ -263,25 +305,39 @@
             Log.d(TAG, "Ignore onProfileConnectionStateChanged, not connected state");
             return;
         }
-        List<LocalBluetoothProfile> supportedProfiles = cachedDevice.getProfiles();
-        boolean isLeAudioSupported = false;
-        for (LocalBluetoothProfile profile : supportedProfiles) {
-            if (profile instanceof LeAudioProfile && profile.isEnabled(cachedDevice.getDevice())) {
-                isLeAudioSupported = true;
-            }
-            if (profile.getProfileId() != bluetoothProfile
-                    && profile.getConnectionStatus(cachedDevice.getDevice())
-                            == BluetoothProfile.STATE_CONNECTED) {
-                Log.d(
-                        TAG,
-                        "Ignore onProfileConnectionStateChanged, not the first connected profile");
-                return;
-            }
+        if (mFragment == null) {
+            Log.d(TAG, "Ignore onProfileConnectionStateChanged, no host fragment");
+            return;
         }
-        // Show stop audio sharing dialog when an ineligible (not le audio) remote device connected
-        // during a sharing session.
-        if (isBroadcasting() && !isLeAudioSupported) {
-            if (mFragment != null) {
+        if (mAssistant == null && mBroadcast == null) {
+            Log.d(
+                    TAG,
+                    "Ignore onProfileConnectionStateChanged, no broadcast or assistant supported");
+            return;
+        }
+        boolean isLeAudioSupported = isLeAudioSupported(cachedDevice);
+        // For eligible (LE audio) remote device, we only check its connected LE audio profile.
+        if (isLeAudioSupported && bluetoothProfile != BluetoothProfile.LE_AUDIO) {
+            Log.d(
+                    TAG,
+                    "Ignore onProfileConnectionStateChanged, not the le profile for le audio"
+                        + " device");
+            return;
+        }
+        boolean isFirstConnectedProfile = isFirstConnectedProfile(cachedDevice, bluetoothProfile);
+        // For ineligible (non LE audio) remote device, we only check its first connected profile.
+        if (!isLeAudioSupported && !isFirstConnectedProfile) {
+            Log.d(
+                    TAG,
+                    "Ignore onProfileConnectionStateChanged, not the first connected profile for"
+                        + " non le audio device");
+            return;
+        }
+        if (!isLeAudioSupported) {
+            // Handle connected ineligible (non LE audio) remote device
+            if (isBroadcasting()) {
+                // Show stop audio sharing dialog when an ineligible (non LE audio) remote device
+                // connected during a sharing session.
                 AudioSharingStopDialogFragment.show(
                         mFragment,
                         cachedDevice.getName(),
@@ -289,6 +345,46 @@
                             mBroadcast.stopBroadcast(mBroadcast.getLatestBroadcastId());
                         });
             }
+            // Do nothing for ineligible (non LE audio) remote device when no sharing session.
+        } else {
+            // Handle connected eligible (LE audio) remote device
+            if (isBroadcasting()) {
+                // Show audio sharing switch or join dialog according to device count in the sharing
+                // session.
+                Map<Integer, List<CachedBluetoothDevice>> groupedDevices =
+                        fetchConnectedDevicesByGroupId();
+                ArrayList<AudioSharingDeviceItem> deviceItems =
+                        buildDeviceItemsInSharingSession(groupedDevices);
+                // Show switch audio sharing dialog when the third eligible (LE audio) remote device
+                // connected during a sharing session.
+                if (deviceItems.size() >= 2) {
+                    AudioSharingDisconnectDialogFragment.show(
+                            mFragment,
+                            deviceItems,
+                            cachedDevice.getName(),
+                            (AudioSharingDeviceItem item) -> {
+                                // Remove all sources from the device user clicked
+                                for (CachedBluetoothDevice device :
+                                        groupedDevices.get(item.getGroupId())) {
+                                    for (BluetoothLeBroadcastReceiveState source :
+                                            mAssistant.getAllSources(device.getDevice())) {
+                                        mAssistant.removeSource(
+                                                device.getDevice(), source.getSourceId());
+                                    }
+                                }
+                                // Add current broadcast to the latest connected device
+                                mAssistant.addSource(
+                                        cachedDevice.getDevice(),
+                                        mBroadcast.getLatestBluetoothLeBroadcastMetadata(),
+                                        /* isGroupOp= */ true);
+                            });
+                } else {
+                    // TODO: show dialog to add device to sharing session.
+                }
+            } else {
+                // Show audio sharing join dialog when no sharing session.
+                // TODO: show dialog to add device to sharing session.
+            }
         }
     }
 
@@ -307,7 +403,71 @@
                         fragment.getMetricsCategory());
     }
 
+    private boolean isLeAudioSupported(CachedBluetoothDevice cachedDevice) {
+        return cachedDevice.getProfiles().stream()
+                .anyMatch(
+                        profile ->
+                                profile instanceof LeAudioProfile
+                                        && profile.isEnabled(cachedDevice.getDevice()));
+    }
+
+    private boolean isFirstConnectedProfile(
+            CachedBluetoothDevice cachedDevice, int bluetoothProfile) {
+        return cachedDevice.getProfiles().stream()
+                .noneMatch(
+                        profile ->
+                                profile.getProfileId() != bluetoothProfile
+                                        && profile.getConnectionStatus(cachedDevice.getDevice())
+                                                == BluetoothProfile.STATE_CONNECTED);
+    }
+
     private boolean isBroadcasting() {
         return mBroadcast != null && mBroadcast.isEnabled(null);
     }
+
+    private Map<Integer, List<CachedBluetoothDevice>> fetchConnectedDevicesByGroupId() {
+        // TODO: filter out devices with le audio disabled.
+        List<BluetoothDevice> connectedDevices =
+                mAssistant == null ? ImmutableList.of() : mAssistant.getConnectedDevices();
+        Map<Integer, List<CachedBluetoothDevice>> groupedDevices = new HashMap<>();
+        CachedBluetoothDeviceManager cacheManager = mLocalBtManager.getCachedDeviceManager();
+        for (BluetoothDevice device : connectedDevices) {
+            CachedBluetoothDevice cachedDevice = cacheManager.findDevice(device);
+            if (cachedDevice == null) {
+                Log.d(TAG, "Skip device due to not being cached: " + device.getAnonymizedAddress());
+                continue;
+            }
+            int groupId = cachedDevice.getGroupId();
+            if (groupId == BluetoothCsipSetCoordinator.GROUP_ID_INVALID) {
+                Log.d(
+                        TAG,
+                        "Skip device due to no valid group id: " + device.getAnonymizedAddress());
+                continue;
+            }
+            if (!groupedDevices.containsKey(groupId)) {
+                groupedDevices.put(groupId, new ArrayList<>());
+            }
+            groupedDevices.get(groupId).add(cachedDevice);
+        }
+        return groupedDevices;
+    }
+
+    private ArrayList<AudioSharingDeviceItem> buildDeviceItemsInSharingSession(
+            Map<Integer, List<CachedBluetoothDevice>> groupedDevices) {
+        ArrayList<AudioSharingDeviceItem> deviceItems = new ArrayList<>();
+        for (List<CachedBluetoothDevice> devices : groupedDevices.values()) {
+            for (CachedBluetoothDevice device : devices) {
+                List<BluetoothLeBroadcastReceiveState> sourceList =
+                        mAssistant.getAllSources(device.getDevice());
+                if (!sourceList.isEmpty()) {
+                    // Use random device in the group within the sharing session to
+                    // represent the group.
+                    deviceItems.add(
+                            new AudioSharingDeviceItem(device.getName(), device.getGroupId()));
+                    break;
+                }
+            }
+        }
+        return deviceItems;
+    }
 }
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragment.java
new file mode 100644
index 0000000..1840f58
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragment.java
@@ -0,0 +1,124 @@
+/*
+ * 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.connecteddevice.audiosharing;
+
+import android.app.Dialog;
+import android.app.settings.SettingsEnums;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.android.internal.widget.LinearLayoutManager;
+import com.android.internal.widget.RecyclerView;
+import com.android.settings.R;
+import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
+import com.android.settings.flags.Flags;
+
+import java.util.ArrayList;
+
+public class AudioSharingDisconnectDialogFragment extends InstrumentedDialogFragment {
+    private static final String TAG = "AudioSharingDisconnectDialog";
+
+    private static final String BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS =
+            "bundle_key_device_to_disconnect_items";
+    private static final String BUNDLE_KEY_NEW_DEVICE_NAME = "bundle_key_new_device_name";
+
+    // The host creates an instance of this dialog fragment must implement this interface to receive
+    // event callbacks.
+    public interface DialogEventListener {
+        /**
+         * Called when users click the device item to disconnect from the audio sharing in the
+         * dialog.
+         *
+         * @param item The device item clicked.
+         */
+        void onItemClick(AudioSharingDeviceItem item);
+    }
+
+    private static DialogEventListener sListener;
+
+    @Override
+    public int getMetricsCategory() {
+        return SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_DEVICE;
+    }
+
+    /**
+     * Display the {@link AudioSharingDisconnectDialogFragment} dialog.
+     *
+     * @param host The Fragment this dialog will be hosted.
+     */
+    public static void show(
+            Fragment host,
+            ArrayList<AudioSharingDeviceItem> deviceItems,
+            String newDeviceName,
+            DialogEventListener listener) {
+        if (!Flags.enableLeAudioSharing()) return;
+        final FragmentManager manager = host.getChildFragmentManager();
+        sListener = listener;
+        if (manager.findFragmentByTag(TAG) == null) {
+            final Bundle bundle = new Bundle();
+            bundle.putParcelableArrayList(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, deviceItems);
+            bundle.putString(BUNDLE_KEY_NEW_DEVICE_NAME, newDeviceName);
+            AudioSharingDisconnectDialogFragment dialog =
+                    new AudioSharingDisconnectDialogFragment();
+            dialog.setArguments(bundle);
+            dialog.show(manager, TAG);
+        }
+    }
+
+    @Override
+    public Dialog onCreateDialog(Bundle savedInstanceState) {
+        Bundle arguments = requireArguments();
+        ArrayList<AudioSharingDeviceItem> deviceItems =
+                arguments.getParcelableArrayList(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS);
+        String newDeviceName = arguments.getString(BUNDLE_KEY_NEW_DEVICE_NAME);
+        final AlertDialog.Builder builder =
+                new AlertDialog.Builder(getActivity())
+                        .setTitle("Choose headphone to disconnect")
+                        .setCancelable(false);
+        View rootView =
+                LayoutInflater.from(builder.getContext())
+                        .inflate(R.layout.dialog_audio_sharing_disconnect, /* parent= */ null);
+        TextView subTitle = rootView.findViewById(R.id.share_audio_disconnect_description);
+        subTitle.setText(
+                "To share audio with " + newDeviceName + ", disconnect another pair of headphone");
+        RecyclerView recyclerView = rootView.findViewById(R.id.device_btn_list);
+        recyclerView.setAdapter(
+                new AudioSharingDeviceAdapter(
+                        deviceItems,
+                        (AudioSharingDeviceItem item) -> {
+                            sListener.onItemClick(item);
+                            dismiss();
+                        }));
+        recyclerView.setLayoutManager(
+                new LinearLayoutManager(getActivity(), LinearLayoutManager.VERTICAL, false));
+        Button cancelBtn = rootView.findViewById(R.id.cancel_btn);
+        cancelBtn.setOnClickListener(
+                v -> {
+                    dismiss();
+                });
+        AlertDialog dialog = builder.setView(rootView).create();
+        dialog.setCanceledOnTouchOutside(false);
+        return dialog;
+    }
+}